Walkthrough: a URL shortener
The full 45-minute arc on the classic warm-up question — where every number is shown, every choice is defended, and the trap is the click counter.
Why this question survives, and how the 45 minutes are spent
"Design a URL shortener" (TinyURL, bit.ly) is the most-asked warm-up in the industry and candidates dismiss it at their peril. It survives because the functional requirements fit in one sentence, which means 100% of your signal comes from how you reason, not from what you build. There is nowhere to hide behind domain complexity. Every senior-level muscle gets exercised: scoping, capacity estimation, an ID-generation choice with real collision math, a read-heavy caching story, and one planted trap that separates people who have run a system from people who have read about one.
Minute 0-5: the clarifying questions worth asking
Ask questions that change the design. "Should it be scalable?" is noise. Each of these has a wrong answer that would send you somewhere else entirely, and you should say why you're asking.
| Question | Why it changes the design | Assume, if they shrug |
|---|---|---|
| What's the traffic — new links per day? | Sets everything downstream: key length, storage, shard count | 10 M creates/day |
| Read to write ratio? | Decides whether this is a caching problem or a write problem | 100:1 — so 1 B redirects/day |
| How long do links live? | Unbounded means 5-year storage math and a reclamation story; TTLs mean the keyspace recycles | Default never expire, optional expiry |
| Custom aliases? | Adds a second, user-controlled namespace with a uniqueness race and a squatting problem | Yes, as an optional field |
| Do we need per-click analytics? | This is the load-bearing one. It decides 301 vs 302, and it introduces a write path 100× the create path | Yes — counts, and coarse geo/referrer |
| Should keys be unguessable? | Rules out a plain counter; forces a scramble, a hash, or a pre-generated pool | Yes — enumerable links leak private URLs |
| Latency target on the redirect? | Sets the caching and geographic strategy | p99 under 100 ms at the edge |
| Global or single region? | Multi-region changes the write path and the consistency story | Global reads, single-region writes |
Then state the requirements back, split into functional and non-functional, and get agreement. Functional: create a short link from a long URL, optionally with a custom alias and an expiry; redirect a short link to its target; report click counts; delete or disable a link. Non-functional: redirects are the hot path and must be fast and highly available; creates can be slower and can tolerate brief unavailability; keys must not be enumerable; a link, once created, must never resolve to the wrong target — that last one is a correctness requirement, not a nicety, and it is what makes key uniqueness a security property rather than a hygiene issue.
Minute 5-10: back-of-envelope, line by line
Write the arithmetic on the board. Not the results — the arithmetic. Round aggressively and say that you're rounding. There are 86,400 seconds in a day; approximating that as 100,000 is standard and keeps the mental math clean, though below I'll use the real figure so the numbers reconcile.
Traffic.
- Writes: 10,000,000 ÷ 86,400 = 116 creates/sec. At 3× peak: ~350/sec.
- Reads: 10,000,000 × 100 = 1,000,000,000 redirects/day. 1,000,000,000 ÷ 86,400 = 11,574 redirects/sec. At 3× peak: ~35,000/sec.
- The two paths are three orders of magnitude apart. That single observation drives the entire rest of the design: separate services, separate scaling, and a cache that only the read path touches.
Storage over 5 years.
- Per row: 7 B short key + ~100 B average long URL + 8 B user id + 8 B created_at + 8 B expires_at + flags ≈ 132 B of payload. With row overhead, the primary index and one secondary index, budget 500 B per link.
- Rows: 10,000,000/day × 365 × 5 = 18.25 billion.
- Storage: 18.25 × 10⁹ × 500 B = 9.125 × 10¹² B = ~9.1 TB. With 3× replication, ~27 TB.
- Read that number back: 9 TB is small. It fits on three commodity volumes. Storage is not the hard part of this problem, and saying so out loud stops you over-engineering the next twenty minutes.
Bandwidth.
- Writes: 116/sec × ~500 B ≈ 58 KB/sec. Nothing.
- Reads: a redirect response is headers plus a
Location, call it 500 B. 11,574/sec × 500 B ≈ 5.8 MB/sec ≈ 46 Mbps; 139 Mbps at peak. Also nothing — one server's NIC. - Conclusion: this is a QPS and key-management problem, not a bandwidth or storage problem. Naming what the problem isn't is a senior move.
Cache sizing.
- Redirect traffic is strongly recency-skewed — most clicks land on links created in the last few days. Cache the ~10 most recent days of creations plus the evergreen tail: 10 × 10,000,000 = 100 million entries.
- Per entry: 7 B key + ~100 B URL + Redis object overhead ≈ 200 B. 100 × 10⁶ × 200 B = 20 GB — a three-node Redis cluster with room to spare.
- At a 95% hit rate the datastore sees 11,574 × 0.05 = 579 reads/sec (1,750/sec at peak). That is the whole point of the estimate: the read tier's job is to shrink 35,000/sec down to something a single replicated store answers without breaking a sweat.
Server count, since it always gets asked: a stateless handler that does one
Redis GET and emits a 302 will do roughly 8-10k requests/sec on
a modest instance. 35,000 ÷ 8,000 ≈ 5, so call it 12 instances across three
availability zones — sized for redundancy and headroom, not for throughput.
Minute 10-16: the API surface and the redirect semantics
| Endpoint | Notes |
|---|---|
POST /api/v1/links{ url, alias?, expires_at? } → 201 { key, short_url, expires_at } | Requires Authorization and an Idempotency-Key header, so a client retry after a timeout does not mint two keys for one user action. Rate-limited per account |
GET /{key} → 302 + Location | The hot path. No auth, no body, no database write. Must be the simplest code in the system |
GET /api/v1/links/{key} | Metadata for the owner; not the redirect path |
DELETE /api/v1/links/{key} | Soft delete + immediate cache purge. Abuse takedown depends on this being fast |
GET /api/v1/links?cursor=&limit= | Cursor pagination, not offset — a heavy account has millions of links |
GET /api/v1/links/{key}/stats?from=&to= | Served from the analytics store, never from the links table |
Then the question that looks trivial and isn't: 301 or 302? A
301 Moved Permanently lets the browser and every CDN cache the
mapping, which is wonderful — your traffic collapses because repeat visitors
never reach you again. That is also exactly the problem: you lose per-click
analytics for repeat visitors, and you can no longer revoke the link.
When a link turns out to point at a phishing page, a 301 you served
yesterday is still redirecting victims from their browser cache and there is
nothing you can do about it. So: 302 (or 307) with
Cache-Control: private, no-store by default, because
revocability and analytics are product requirements; offer 301 as a
per-link option for high-volume customers who don't need either. Saying
that tradeoff, rather than picking a number, is the answer.
Key generation: three strategies and the collision math
This is the technical core of the question. First, key length. Base62
(a-z A-Z 0-9) gives you 62 characters per position:
- 62⁶ = 56,800,235,584 ≈ 5.68 × 10¹⁰
- 62⁷ = 3,521,614,606,208 ≈ 3.52 × 10¹²
We need 18.25 × 10⁹ keys. Against 62⁶ that is 18.25 ÷ 56.8 = 32% of the keyspace — fine for a counter, disastrous for random generation, since by year five roughly one in three random draws would collide. Against 62⁷ it is 18.25 ÷ 3,521.6 = 0.52%. So: 7 characters if keys are random, 6 if they come from a counter. That difference is not cosmetic — a counter uses the keyspace densely, which is precisely what randomness gives up.
| Hash + truncate | Counter + base62 | Pre-generated key pool | |
|---|---|---|---|
| Mechanism | SHA-256(url + salt), base62-encode, take the first 7 chars | Global monotonic counter, base62-encode the integer | An offline service generates unique random keys into a table and hands out blocks |
| Collisions | Expected ≈ N²/2M = (1.825×10¹⁰)² ÷ (2 × 3.52×10¹²) ≈ 47 million over 5 years — ~1 in 400 inserts on average, ~1 in 190 by year 5 | Zero, by construction | Zero — uniqueness is enforced once, offline, at generation time |
| Write path | Conditional insert (ON CONFLICT DO NOTHING), re-salt and retry on conflict. Never read-then-write — that races | One unconditional insert | One unconditional insert; the key was already reserved |
| Guessable? | No | Yes — sequential keys let anyone enumerate every link and infer your daily volume. Needs a bijective scramble (a keyed Feistel permutation over the integer) to fix | No |
| Key length needed | 7 | 6 | 7 |
| Same URL twice | Same key — free dedupe, but two users now share one link's analytics and either can delete it. Salt with the user id if that matters | Different keys | Different keys |
| New moving parts | None | A counter service, and range allocation so it isn't a per-write hotspot | A key-generation service, its own store, and a strictly transactional handout |
| Reach for this when… | You want zero extra infrastructure and can tolerate a retry loop on the write path | You control the whole system, want the shortest keys, and will do the scramble properly | You want the write path to be a single unconditional insert with no collision logic anywhere — the choice at genuinely large scale |
The counter approach deserves one more number, because it kills the obvious objection. Nobody increments a shared counter 116 times a second; you allocate ranges. Each app server takes a block of 1,000,000 ids and serves creates from local memory. At 10 M creates a day, the fleet consumes ten blocks per day — the counter service handles about ten requests in twenty-four hours, and can be a single row in Postgres behind a transaction. If a server crashes, its unused ids are lost: worst case a full fleet restart burns 12 × 1,000,000 = 12 million ids, which is 0.02% of 62⁶. Deliberately wasting 12 million ids to avoid a distributed counter is the correct engineering trade, and saying it that plainly is the signal.
Sizing the pre-generated pool, if you go that way: hold six months of buffer, 10 M/day × 180 = 1.8 × 10⁹ keys at 8 B each = 14.4 GB — a single table. Generate replacements at 116/sec, which is trivial. The one thing you must get right is the handout: two servers receiving the same key block means two links resolving to the same short code, which is a security bug, not a glitch. Hand out blocks inside a transaction that marks them taken, and accept losing a block when a server dies.
SELECT then INSERT is a race: two concurrent
creates both see "free" and both insert, and now one of them silently
overwrote the other's link. The redirect for that key now sends users to the
wrong site. It must be a single conditional write — INSERT … ON
CONFLICT DO NOTHING in Postgres, a conditional
attribute_not_exists(key) in DynamoDB — with a retry using a
different salt when zero rows are affected. Interviewers plant this one
deliberately; getting it right takes one sentence and getting it wrong
undoes a good design.
Data model, and the trap in the click counter
The read path does exactly one thing: point lookup by primary key. No joins, no ranges, no sorting. That is a pure key-value access pattern, which tells you the storage engine barely matters and the sharding key is obvious.
- links —
short_key(PK, char(7)),long_url,user_id,created_at,expires_at,is_active. Sharded byhash(short_key): uniform distribution, and every redirect knows its shard from the URL alone with no lookup. - links_by_user — a secondary index or GSI on
user_id, for the low-volume "list my links" API. Do not let this exist on the redirect path. - click events — a separate system entirely. Not a column. Not on this table. Not in this database.
The arithmetic: naive is 11,574 × 60 = 694,440 writes/min. Buffered is 12 instances × 6 flushes/min = 144 writes/min, a 4,800× reduction, and the redirect path now performs zero writes. If you want durable, attributable click events (geo, referrer, timestamp) rather than just counts, publish one event per redirect to Kafka and aggregate downstream — same principle, the redirect still never touches the primary store, and the analytics pipeline scales on its own budget.
Minute 16-30: the architecture
Walk the two paths out loud. Create: authenticate, validate and
normalise the URL, check the idempotency key, take the next id from the
in-memory block, write the row, populate the cache optimistically, return
201. Redirect: GET the key from Redis; on a hit emit a
302 immediately; on a miss read the shard, populate the cache with a
jittered TTL, emit the 302; increment an in-process click counter and return.
Nine times out of ten the entire request is one memory lookup in a
co-located Redis and a response with no body.
Minute 30-45: the scaling path and the follow-ups
| Stage | Trigger | What changes | What it buys |
|---|---|---|---|
| 1. One box | Launch | App + Postgres + Redis on one machine | Genuinely serves millions of redirects a day. Start here and say so |
| 2. Split and scale the read tier | CPU on the app | Stateless redirect instances behind an L7 LB, dedicated Redis | Linear read scaling; the DB is now protected by a 95% hit rate |
| 3. Read replicas | Cache misses saturate the primary | Route redirect misses to replicas; creates stay on the primary | Read capacity without sharding. Replica lag is harmless — the row is immutable |
| 4. Shard the store | Data past a few TB, or write IOPS | Hash-shard on short_key; or move to DynamoDB/Cassandra, which is what this access pattern wants | Horizontal storage and write scaling with no cross-shard queries, ever |
| 5. Go multi-region | p99 for distant users, or a regional outage requirement | Read replicas or a full cache per region; writes still home to one region; edge caching on the 302 itself | Redirect latency from ~120 ms to ~20 ms globally. Safe precisely because the mapping is immutable |
| 6. Split analytics off | Anyone asks for dashboards | Click events to Kafka, stream aggregation, columnar store for queries | Analytics load never touches the redirect path |
The follow-ups an interviewer will reach for, and the one-line answers:
- A link goes viral — one key gets 50,000 rps. That's a single hot Redis shard. Add a per-instance L1 cache with a 1-5 second TTL in front of Redis: 12 instances refreshing once a second is 12 reads/sec to Redis no matter how viral the link gets. The data is immutable, so a few seconds of staleness costs nothing.
- How do you expire 18 billion rows? Never a
DELETE … WHERE expires_at < now()across the table. Partition by creation month so expiry is aDROP PARTITION, plus lazy deletion: if a read finds an expired row, return 410 and evict. - Custom aliases racing. Same conditional insert as key generation, plus a reserved-word list (
api,login,admin) so a user can't claim a path that shadows your own routes. - Abuse and phishing. Scan the target on create against a reputation service; hold new links from untrusted accounts behind an interstitial; rate-limit creates per account and per IP. All of this depends on being able to revoke instantly — which is the argument for 302 you already made.
- What do you monitor? Cache hit rate (an early-warning signal for everything), p99 redirect latency, key-block depth per instance, create error rate, and the 404 rate on redirects, which spikes when a shard is misrouted.
- What breaks first? Be specific: the Redis cluster. At 95% hit rate the store sees 579 reads/sec; lose one of three cache nodes and the miss rate goes to roughly 0.33 + 0.67 × 0.05 = 0.365, sending 11,574 × 0.365 ≈ 4,200 reads/sec at the store — a 7× spike. Consistent hashing keeps a node loss from reshuffling the other two, and the store must be sized for the degraded number, not the healthy one.
What the interviewer was actually scoring
The rubric is never "did you produce the reference architecture." Almost everyone converges on roughly the same boxes. These are the axes that actually get written on the feedback form:
- Did you drive? Whether you scoped the problem yourself or waited to be handed requirements is usually decided in the first three minutes, and it is the single most common reason a strong engineer gets down-levelled here.
- Did a number change a decision? Estimation is not a ritual. The 9 TB figure is what justified not proposing an exotic distributed store; the 100:1 ratio is what justified splitting read and write services; the 20 GB cache figure is what made "95% hit rate" a claim rather than a hope. If your estimates didn't visibly steer anything, you performed the ritual without doing the work.
- Did you find the click counter? This is the planted trap. Spotting that per-click writes are 100× the create load — unprompted — is the strongest single signal available in this question.
- Did you commit? Comparing three key-generation strategies and then not choosing one reads as indecision, not rigour. Pick one, name what you're giving up, and say what would make you switch.
- Was your design proportionate? Reaching for Cassandra, Kafka and a stream processor in minute four for a system one Postgres box would serve is scored as a negative at senior level. Knowing the smallest thing that works, and knowing exactly which metric would force the next step, is the whole skill.
- Did you know your correctness boundaries? That the mapping is immutable (so replica lag and stale caches are harmless), that click counts are eventually consistent and may lose ten seconds (fine), but that key uniqueness must be enforced by a conditional write (not fine to hand-wave, because the failure is a link resolving to the wrong site).
- Did the follow-up land? "Now make it 100×" or "now it's multi-region" is a test of whether your design had joints. A design where the answer is "add more redirect instances and a regional cache" was built by someone who has scaled something; a design where the answer is "I'd rebuild it" was not.
- Level tell: a mid-level answer produces a design that works. A senior/staff answer additionally names the tradeoff it is accepting, quantifies the cost of that trade, states what it is deliberately not building, and identifies the metric that would change its mind.