URL Shortener
One-read 301 hot path, DynamoDB TTL expiry, async click analytics via SQS
Open & fork this on Zstem →Architecture
- Browser: Visitor clicking a short link, or the dashboard creating one
- Links API: HTTP API: POST /links (create), GET /{slug} (redirect hot path)
- Redirect Fn: The hot path: one DynamoDB read → 301. Emits link.clicked to SQS fire-and-forget so analytics never slows a redirect
- Create Link Fn: Validates the target URL, generates a collision-checked slug, writes the link with optional TTL expiry
- Links Table: slug → target URL. DynamoDB TTL deletes expired links automatically — no cleanup job
- Click Events Queue: Buffers link.clicked events; absorbs redirect traffic spikes so analytics scales independently
- Analytics Fn: Batch-consumes click events and increments per-day rollup counters (ADD — idempotent-ish, cheap)
- Click Rollups: Per-link, per-day click counts powering the stats view
Event flow
- Redirect Fn: Emits link.clicked after every successful 301 — fire-and-forget, never blocks the redirect
- Click Events Queue: SQS buffer — absorbs spikes, retries consumer failures, DLQ after 3 attempts
- Analytics Fn: Batch-consumes (25/poll) and ADDs per-day rollup counters
- link.clicked: One click on a short link; consumed for per-day rollups
Sequence
Participants: Visitor, Links API, Redirect Fn, Links Table, Click Queue
- Visitor→Links APIGET /{slug}
- Links API→Redirect Fninvoke
- Redirect Fn→Links TableGetItem(slug)
- Links Table→Redirect Fntarget_url
- Redirect Fn→Click Queuelink.clicked (async, fire-and-forget)
- Redirect Fn→Links API301 + Location
- Links API→Visitor301 Moved Permanently
- Visitor→Links APIGET /{unknown-slug}
- Links API→Visitor404 Not Found
API contract
- POST
/linksCreate a short link - GET
/{slug}Redirect to the target URL (hot path) - GET
/links/{slug}/statsPer-day click rollups for a link
Database
| slug | Short-link slug — partition key. Vanity or generated (base62, collision-checked with a conditional put). |
|---|---|
| target_url | Where the 301 points. |
| created_at | |
| created_by | Optional owner for a future dashboard. |
| ttl | Epoch seconds. DynamoDB TTL deletes expired links automatically — the redirect answers 410 between logical expiry and physical delete. |
| slug | Partition key — FK to links.slug. |
|---|---|
| day | Sort key: one item per link per day. |
| clicks | Incremented with ADD by the analytics consumer — no read-modify-write. |
Design Notes
URL Shortener — design notes
A deliberately small serverless system that optimises ONE thing: the redirect hot path.
The hot path is sacred
GET /{slug} does exactly one DynamoDB read and answers 301. Everything else — click
tracking, stats, rollups — happens after the redirect, asynchronously. The visitor never
waits for analytics.
One read: GetItem on the links table by slug (partition key). Single-digit ms.
301, not 302: browsers and crawlers cache a permanent redirect, so repeat clicks may
not even hit us. (Trade-off: cached redirects skip click tracking — acceptable; rollups are
a trend signal, not billing.)
Fire-and-forget click events: the redirect Lambda SendMessages link.clicked to SQS
and does NOT await delivery guarantees beyond the SDK call. A dropped click is a rounding
error; a slow redirect is a product failure.
TTL expiry without a cleanup job
Links can carry an expiresAt, stored as the DynamoDB TTL attribute (epoch seconds).
DynamoDB deletes expired items itself — no scheduled sweeper Lambda, no scan costs.
Between logical expiry and physical delete the redirect checks the TTL and answers 410.
Async analytics via SQS
The queue absorbs redirect spikes so the analytics consumer scales independently.
The consumer processes batches of 25 and increments per-link-per-day counters with an
ADD update — no read-modify-write, no contention.
Failures retry via the queue; poison messages land in a DLQ after 3 attempts.
Slug generation
Vanity slugs are accepted if free; generated slugs are short base62, written with a
ConditionExpression: attributenotexists(slug) and regenerated on the (rare) collision —
the same conditional-put pattern used for any uniqueness guarantee in DynamoDB.
What this design deliberately skips
Custom domains per user, auth on link creation (add Cognito in front of POST /links),
real-time dashboards (rollups are eventually consistent by design), and bot filtering
(user-agent heuristics can be added in the analytics consumer without touching the hot path).