Design a URL shortener
The warm-up problem. Small surface area, one interesting property: reads outnumber writes by a hundred to one.
What you will be able to do
- Generate short codes with no collision-check bottleneck
- Size storage and cache from stated assumptions
- Keep redirect latency low with a cache-first read path
- Handle expiry and custom aliases without a full scan
Requirements
- Functional: create a short link for a URL, redirect on visit, optional custom alias and expiry, basic click counts.
- Non-functional: redirect under 50 ms at p99, 100:1 read-to-write ratio, links live for years, unavailable links return 404 or 410.
- Out of scope: user accounts, dashboards, abuse scanning beyond a denylist.
Estimates
writes 100M new links/day = ~1,200/s reads 100x writes = ~120,000/s row ~500 B (url + code + meta) storage 100M x 500 B = 50 GB/day, ~18 TB/year cache 20% hot links, 7 days = ~140M rows, ~70 GB codes 62^7 = 3.5 trillion -> 7 chars is plenty
API
| Endpoint | Purpose | Notes |
|---|---|---|
| POST /links | Create a short link | Body: url, optional alias, optional ttl |
| GET /{code} | Redirect | 301/302 to the target, no JSON |
| DELETE /links/{code} | Revoke | Owner-scoped, marks inactive |
| GET /links/{code}/stats | Click counts | Eventually consistent |
Use 302 by default so clicks keep flowing through your service for analytics; 301 lets browsers cache the redirect permanently, which is faster but hides future traffic and makes revocation unreliable.
Generating the code
Read path and bottlenecks
The redirect path is: edge cache, then a shared Redis cache, then the database. With a 90% cache hit rate, 120k reads/s becomes 12k database reads/s, which a single primary with read replicas handles comfortably. Cache misses are coalesced so a popular new link does not stampede.
GET /abc1234 edge cache hit? -> 302 (most requests end here) redis GET code -> hit: 302, refresh edge TTL db select -> populate redis, 302 missing/expired -> 404 / 410 click event -> append to stream (async) -> aggregation job
Analytics must never be on the redirect path. Emit an event to a queue and aggregate offline; losing a few click events is acceptable, adding 5 ms to every redirect is not. Expiry is stored as a column and checked on lookup, with a background job deleting in batches using an index on expiry time.
Worth remembering
- 65 billion codes fit in seven Base62 characters, so length is arithmetic, not taste.
- A pre-allocated counter range per instance avoids collisions without coordination per request.
- Redirects must be cached at the edge; analytics should be asynchronous.
- Expiry belongs in the lookup, not in a nightly cleanup job that races with reads.