B7 · Part 1 · Beginner
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.
09On this page
- Why this question survives, and how the 45 minutes are spent
- Minute 0-5: the clarifying questions worth asking
- Minute 5-10: back-of-envelope, line by line
- Minute 10-16: the API surface and the redirect semantics
- Key generation: three strategies and the collision math
- Data model, and the trap in the click counter
- Minute 16-30: the architecture
- Minute 30-45: the deep dive, the scaling path and the follow-ups
- What the interviewer was actually scoring
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 how long (if at all) a redirect may be cached, 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 (keys are still not secrets: truly private targets need authentication) |
| 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.
Say it like this → "Before I draw anything: I'm
assuming 10 million creates a day, roughly 100 reads per write, links that
live indefinitely by default, and that we need per-click analytics. The
analytics answer is the one I care most about, because it turns a
116-writes-per-second system into an 11,574-writes-per-second system, and
I'd design those two paths very differently. Sound right?"
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 two orders of magnitude apart (11,574 ÷ 116 is about 100×; 35,000 ÷ 350 is exactly 100×). 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.
One database sanity check against the course's planning figures (about 20,000 simple indexed reads/s and 5,000 committed writes/s for one relational primary; conservative numbers, not limits): 35,000 redirects/s of peak traffic would exceed one primary on reads, which is why the cache exists, and the 11,574 click-count writes/s of the naive design would exceed its write figure by more than 2×.
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 (an IETF draft convention, not yet an RFC, but widely used by payment APIs), 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: which redirect status,
and how long may it be cached? The status code sets a default; the
Cache-Control header overrides it. RFC 9110 marks
301 and 308 as heuristically cacheable, so a bare
301 can sit in a browser (and in any shared cache) for a long time and you
never see the repeat visit. 302 and 307 carry no
such default and are cached only if the response says so. That gives you a
menu, not a binary choice:
| Option | Headers | Clicks you see | Time to revoke a link | Use it when |
|---|---|---|---|---|
| A. Never cached | 302 or 307 + Cache-Control: no-store (or private, max-age=0) | Every click, including repeats from the same browser | Immediate for browsers; bounded by your own L1/Redis TTL and purge on your side | Default. Analytics and fast takedown are product requirements |
| B. 301 with a short private TTL | 301 + Cache-Control: private, max-age=90 | One click per browser per 90 s; repeats inside the window are served from the browser cache | Up to the TTL (90 s here), and only for browsers that already have it, because private keeps CDNs and proxies from storing it | You want 301 semantics and fewer repeat hits, and can state a takedown window of "a couple of minutes" |
| C. Edge-cached, purge on delete | 302 + Cache-Control: max-age=0, s-maxage=3600 + a purge-by-tag call when a link is disabled | Only cache misses reach you; count clicks from edge logs instead | The purge time: a few seconds or less on modern CDNs (Cloudflare reports a global p50 under 150 ms for tag purges); budget tens of seconds for slow or tiered setups | Global latency matters (scaling stage 5) and you have a CDN with a fast purge API |
| D. Bare 301 or 308 | 301 or 308, no cache headers | Roughly one click per browser, ever | Effectively never, for browsers that have cached it | Permanent, non-sensitive links where you accept no analytics and no takedown. Not for a public shortener |
Real shorteners sit at option B: when this chapter was checked in October
2026, a bit.ly link answered with 301 and
Cache-Control: private, max-age=90. The old advice that
"301 makes a phishing link irrevocable" is true of option D and wrong for B:
a short private TTL bounds the damage to a stated number of seconds.
Pick A by default, offer B or C per link or per customer, and say the
revocation window of whatever you pick out loud. 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. The same density works against you when someone guesses keys: with 6 characters, 32% of every possible key is a live link by year five, so a random probe hits a real link about one time in three even if the counter is scrambled. At 7 characters the density is 0.52%, about one hit per 190 guesses. Either way the key is an identifier, not a secret.
| 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 390 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); on conflict, read the existing row: same long URL and owner means return that key, a different URL means re-salt and retry. 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 hide the order, but at 6 characters the space is 32% occupied by year 5, so random probing still hits about 1 in 3 | No |
| Key length needed | 7 | 6 (7 if you also want sparse keys) | 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.
⚠ "I'll hash the URL and check if it exists"
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 — and when zero rows are
affected, read the existing row: if it holds the same URL for the same owner,
return its key, and only if it holds a different URL retry with a different
salt. 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 planted trap: UPDATE links SET clicks = clicks + 1
It is the natural thing to write and it destroys the design. Your create
path is 116 writes/sec; adding a counter update to every redirect makes it
11,574 writes/sec — a 100× increase, and more than twice a single
primary's conservative 5,000-writes/sec planning figure — aimed at the exact rows you are
trying to read, with row-level lock contention concentrated on whichever
links are popular. A single viral link means thousands of serialised writes
per second to one row, replication lag behind it, and cache invalidation
storms because the row keeps changing. The read-heavy system you just sized
quietly became a write-heavy one.
The arithmetic: naive is 11,574 × 60 = 694,440 writes/min. Buffered is
12 instances × 6 flushes/min = 72 batched writes/min, and 694,440 ÷ 72
is about 9,600× fewer write transactions; the redirect path itself now
performs zero writes. Be honest about the other number, though: a
flush writes one delta per distinct link that instance saw in the
10-second window. Each instance handles about 11,574 ÷ 12 ≈ 965 redirects a second, about 9,650 in a window. If they touch an illustrative 500 distinct links, a flush carries
about 500 rows and the fleet writes 72 × 500 = 36,000 row updates a minute,
roughly 19× fewer rows than the naive 694,440. With a long tail where nearly
every click is a different link, the row count barely falls; with a viral link
it collapses. That is why the counts go to an append-only stream and rollup
store rather than into the links table at all. 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.
Nineteen times out of twenty (the 95% hit rate) the entire request is one
memory lookup in a co-located Redis and a response with no body.
Why this design is easy, in one sentence
Once created, a short link's destination is immutable — the mapping
never changes; only its state (active, disabled, expired) can. Immutable,
tiny, read-heavy data is the friendliest possible thing to cache and
replicate, which is why a system doing a billion reads a day needs no
consistency protocol for the mapping. The one thing that does need prompt
propagation is a takedown, so budget for it explicitly (below). Say this, and the interviewer knows you
understand why it's easy rather than just that it is.
Minute 30-45: the deep dive, 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 for the mapping, which never changes; it is not harmless for a takedown, so disable and delete on the primary, purge caches explicitly, and accept a short window where a lagging replica can still serve the old row |
| 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; optionally edge caching of the redirect with an explicit s-maxage and purge-on-delete (option C above), counting clicks from edge logs | Redirect latency from a cross-ocean round trip of 200 ms or more down to tens of milliseconds. Safe because the mapping is immutable, with the takedown window as the one cost |
| 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 at most 12 reads/sec to Redis no matter how viral the link gets. The destination is immutable, so a few seconds of staleness costs nothing, with one exception: that TTL is also your takedown window for a disabled link unless the delete publishes an invalidation to every instance.
- How do you expire 18 billion rows? Never a
DELETE … WHERE expires_at < now()across the table. Prefer a store with native TTL (DynamoDB TTL, Cassandra TTL), where expiry is a background delete the store schedules; in Postgres, sweep expired rows in small batches via an index onexpires_at. Partitioning so that expiry is aDROP PARTITIONonly works if you partition by expiry time, which makes the point lookup onshort_keyprobe every partition, so it is a poor fit here. Add lazy deletion on top: if a read finds an expired row, return 410 and evict. - Attackers hammer random keys. Every miss on a nonexistent key would fall through to the store. Put a Bloom filter of live keys (or a short negative-cache entry for 404s) in front of it, and rate-limit by IP.
- Where does the counter service live? One Postgres row is fine at ten block grants a day; if you want it off your database, a coordinator such as ZooKeeper or etcd can hand out the same ranges. Snowflake-style time-based ids also work but need 64 bits, which is 11 base62 characters, far longer than the 6 or 7 you budgeted.
- 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 quickly, within a window you can state — which is the argument for a non-cached (or short-TTL) redirect 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 1/3 + 2/3 × 0.05 = 0.367, sending 11,574 × 0.367 ≈ 4,250 reads/sec at the store — about a 7× spike (about 12,700/sec at 3× peak, which is within a single primary's ~20,000 reads/sec planning figure but leaves no headroom). 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.
Dry run: the six phases of this exact interview, with the numbers each one produced
| Phase | Time | What got decided | Number that drove it |
|---|---|---|---|
| Clarify | 0-5 min | 10M creates/day, 100:1 read:write, links live indefinitely, per-click analytics required, keys unguessable | The analytics answer alone turns 116 writes/sec into 11,574 writes/sec — the single fact that shapes everything after it |
| Estimate | 5-10 min | Separate the read and write paths; size storage, bandwidth, and cache | 350 peak creates/sec vs 35,000 peak redirects/sec — two orders of magnitude (100×) apart; ~27 TB replicated storage; 20 GB cache at 95% hit |
| API + model | 10-16 min | Six endpoints; the redirect handler takes no auth and does no write; 302 with no-store chosen by default, 301 with a 90 s private TTL offered as an option | A bare 301 can sit in browser caches indefinitely, making a phishing link effectively irrevocable; analytics and a bounded takedown window rule it out as the default |
| Core design | 16-30 min | Counter + base62 for key generation; buffer click counts in memory instead of writing on every redirect | 6-char keys need zero collision handling; buffering cuts click write transactions from 694,440/min to 72/min, about 9,600× fewer (rows per batch depend on how many distinct links were clicked) |
| Deep dive | 30-38 min | The interviewer's pick; here the viral key and the takedown window: a 1-5 second L1 cache in front of Redis, and an explicit purge on delete | A 50,000 rps key costs Redis at most 12 reads/sec with 12 instances and a 1-second L1 TTL; at a 95% hit rate the store sees only 579 reads/sec on average |
| Scale + wrap | 38-45 min | Six-stage scaling path, each gated by a named trigger, not a fixed timeline | Losing one of three Redis nodes sends store traffic from 579 to ~4,250 reads/sec — the store must be sized for that, not for the healthy case |
Every number in the right-hand column was computed earlier in this same walkthrough — nothing here is new arithmetic. That is the point of the exercise: a senior answer is not six separate good ideas, it is one chain where each phase's output is the next phase's input.
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, except for takedowns, where the cache TTL is your revocation window), 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.
Before you move on
- State the peak writes/sec and peak reads/sec for this exact scenario from memory, and explain why the two-order-of-magnitude (100×) gap between them drives the whole architecture.
- Compute the base62 key length needed for 18.25 billion keys under random generation versus a counter, and explain why the two strategies need different lengths, and why a 6-character scrambled counter is 32% dense by year five.
- Spot the planted click-counter trap and quantify why writing on every redirect turns a read-heavy system into a write-heavy one.
- Justify a non-cached 302 as the default using both the analytics requirement and the revocability requirement, and describe the 301-with-short-private-TTL alternative with its revocation window.
- Walk the six-stage scaling path and name the trigger metric that gates each stage, not just the stage's name.
Sources and further reading
- RFC 9110: HTTP semantics — 301 and 308 are heuristically cacheable; 302 and 307 carry no such default
- RFC 9111: HTTP caching — how Cache-Control, including private and max-age, overrides the defaults
- Cloudflare: instant purge — global purge-by-tag latency of under 150 ms at the median
- IETF draft: the Idempotency-Key header — still an Internet-Draft, not an RFC
- Stripe API: idempotent requests — a production example of the idempotency-key pattern used on the create endpoint
- OpenAI: scaling PostgreSQL — why one relational primary can go far beyond the conservative planning figures used here
The bit.ly response headers quoted in this chapter were observed directly in October 2026 and can change at any time. Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.
5 questions drawn from 7, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.
Design a URL shortener
Thanks for coming in. Today I would like you to design a URL shortener: a service where a user submits a long web address, gets back a short link, and anyone who opens the short link lands on the original page. Walk me through it the way you would in a real design review, starting from what you would ask me.
In scope: creating a short link, redirecting visitors, counting clicks per link, and disabling a link. Out of scope: user sign-up and login, custom aliases, link expiry, and analytics dashboards. I have deliberately left some requirements open, so ask.
Planning assumptions for this exercise (they are figures to design against, not facts about any real service):
- 10 million new short links are created per day.
- Each link is opened 100 times for every link created.
- Peak traffic is 3 times the daily average. Use 86,400 seconds per day.
- Links are kept for 5 years with no expiry rule, and each stored link costs about 500 bytes including indexes.
- Short keys are made of letters and digits (62 characters), and visitors must not be able to list every link by counting upward through the keys.
- Every click should be counted. Counts may lag by a few seconds, and losing a few seconds of clicks in a crash is acceptable.
- An abusive link must stop redirecting everywhere within a couple of minutes of being disabled.
Work it out on the whiteboard or on paper first. Nothing on this card is saved, and the checklist stays hidden until you ask for it.
Finished reading?
0 of 36 chapters read in this series