Design a URL Shortener
A classic warm-up problem precisely because it's small enough to fully design in an interview, while still forcing real decisions about ID generation, read/write ratios, and caching — following the six-step framework from How to Answer a System Design Interview Question.
1. Requirements​
Functional:
- Given a long URL, return a short one.
- Given a short URL, redirect to the original long URL.
- Optionally support custom aliases and an expiration date.
Non-functional:
- Redirects must be low-latency — this is on the critical path of every click a user makes.
- Reads vastly outnumber writes: far more people click a short link than create one.
- Availability matters more than strict consistency here — a redirect service being briefly unreachable is worse than it occasionally serving a slightly stale mapping, so this leans AP under the CAP Theorem.
Scale estimate: assume 100M new URLs created per month (~40 writes/sec) and a 100:1 read/write ratio (~4,000 redirects/sec). At an average of ~100 bytes per stored record, a year of writes is roughly 1.2B records — well within a single well-indexed table's comfortable range, but enough that the read path needs real caching.
2. API Design​
POST /urls
body: { long_url: string, custom_alias?: string, expires_at?: timestamp }
returns: { short_code: string }
GET /{short_code}
returns: 302 Found, Location: <long_url>
A 302 (temporary) redirect, not a 301 (permanent), is the deliberate choice here: a 301 is cached by browsers indefinitely, which would prevent ever changing or expiring a mapping, and would also mean the redirect service stops seeing repeat traffic for popular links — directly undermining the analytics most real link shorteners want to collect.
3. Data Model​
urls
short_code VARCHAR PRIMARY KEY
long_url TEXT
created_at TIMESTAMP
expires_at TIMESTAMP NULL
short_code as the primary key is the entire access pattern this table needs to serve: every redirect is a lookup by that single key, which is exactly the shape a Database Index (here, the primary key index itself) turns into an O(log n) or better lookup rather than a scan.
4. High-Level Design​
Stateless app servers behind a load balancer handle both endpoints; a cache sits in front of the database specifically because the read/write estimate above showed reads dominating by two orders of magnitude — this is the single highest-leverage addition given that access pattern, turning most redirects into a cache hit that never touches the database at all.
5. Deep Dive: generating the short code​
This is the interesting question the rest of the design exists to support, and there are two standard approaches worth naming and comparing directly:
- Random generation + collision check — generate a random string (e.g. 7 base62 characters), check if it's already taken, retry on collision. Simple, but every write pays for at least one lookup, and collision probability rises as the table fills up.
- Counter-based encoding — maintain a globally unique, monotonically increasing counter (or a range of counter values handed out to each app server to avoid contention) and encode it in base62. Guarantees no collisions by construction and needs no collision check at all, at the cost of needing a coordination point to hand out unique counter values or ranges without two servers issuing the same one.
A Bloom Filter of already-issued codes is a natural optimization on top of either approach: checking it first turns "is this code taken" into a cheap, fast, mostly-negative check, only falling through to a real database lookup on the rare case the filter says "maybe."
6. Tradeoffs​
The random-with-collision-check approach is simpler to reason about and needs no shared counter infrastructure, but degrades (more retries) as the keyspace fills. The counter-based approach guarantees no collisions and no retries, at the cost of a coordination point for handing out ranges — a good concrete example of the kind of tradeoff Distributed Locking or a simple range-allocation table exists to solve.
The cache from the high-level design raises one small edge case worth naming: a link that's deleted or hits its expires_at needs its cache entry evicted along with the database row, or the cache would keep happily serving a redirect the system no longer considers valid. The simplest fix is setting the cache entry's own TTL to match expires_at at write time, so an expired mapping ages out of the cache on its own instead of needing an explicit delete on every expiration.
Random generation vs. counter-based short codes: pros and cons​
Counter-based
- No collisions possible by construction — no retry logic needed
- Encoding is a pure function of the counter, trivially fast to compute
- Codes can be made non-guessable by shuffling the counter's bits before encoding
Random + collision check
- Every write pays for at least one collision-check lookup
- Collision probability rises as more of the keyspace fills up
- Retries under load add latency variance that a pure counter never has
Further Reading​
- System Design Primer — Design a URL Shortener — a widely referenced worked solution covering the same core design.
- Bitly Engineering Blog — real-world engineering posts from a company operating URL shortening at scale.
Saved locally in your browser — visible in the sidebar as you go.