Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

24 chapters
⌕
Beginner7›
B1The mental modelB2Client-server basicsB3Vertical vs horizontalB4Databases in designB5Caching fundamentalsB6APIs & communicationB7Walkthrough: URL shortener
Intermediate9›
I1Load balancing in depthI2CDNsI3Message queues & asyncI4Consistency modelsI5Rate limiting & throttlingI6Designing for availabilityI7Storage systemsI8Search systemsI9Walkthrough: feed/chat
Advanced8›
A1CAP theorem in depthA2Sharding at scaleA3Distributed consensusA4Fault toleranceA5Observability at scaleA6Capacity estimationA7Case studiesA8Tradeoff thinking
/ search[ ] chaptert top

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
B7

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.

where the 45 minutes actually go 5 min 5 min 6 min 14 min 8 min 7 min clarify estimate API + model core design deep dive scale + wrap the ten minutes candidates skip and then design the wrong system every later phase inherits the numbers you agreed on in the first two
The deep dive is where the level gets decided, but its topic is chosen by the interviewer based on what you said in the first ten minutes. Rushing to boxes-and-arrows forfeits that.

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.

QuestionWhy it changes the designAssume, if they shrug
What's the traffic — new links per day?Sets everything downstream: key length, storage, shard count10 M creates/day
Read to write ratio?Decides whether this is a caching problem or a write problem100: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 recyclesDefault never expire, optional expiry
Custom aliases?Adds a second, user-controlled namespace with a uniqueness race and a squatting problemYes, 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 pathYes — counts, and coarse geo/referrer
Should keys be unguessable?Rules out a plain counter; forces a scramble, a hash, or a pre-generated poolYes — enumerable links leak private URLs
Latency target on the redirect?Sets the caching and geographic strategyp99 under 100 ms at the edge
Global or single region?Multi-region changes the write path and the consistency storyGlobal 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,000-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 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

EndpointNotes
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 + LocationThe 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 + truncateCounter + base62Pre-generated key pool
MechanismSHA-256(url + salt), base62-encode, take the first 7 charsGlobal monotonic counter, base62-encode the integerAn offline service generates unique random keys into a table and hands out blocks
CollisionsExpected ≈ 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 5Zero, by constructionZero — uniqueness is enforced once, offline, at generation time
Write pathConditional insert (ON CONFLICT DO NOTHING), re-salt and retry on conflict. Never read-then-write — that racesOne unconditional insertOne unconditional insert; the key was already reserved
Guessable?NoYes — sequential keys let anyone enumerate every link and infer your daily volume. Needs a bijective scramble (a keyed Feistel permutation over the integer) to fixNo
Key length needed767
Same URL twiceSame key — free dedupe, but two users now share one link's analytics and either can delete it. Salt with the user id if that mattersDifferent keysDifferent keys
New moving partsNoneA counter service, and range allocation so it isn't a per-write hotspotA 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 pathYou control the whole system, want the shortest keys, and will do the scramble properlyYou 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 — 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 by hash(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 — 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.
naive buffered redirect path redirect path UPDATE clicks + 1 on every redirect in-process counter per key, per instance primary datastore flush every 10 s → event stream → rollups 694,000 writes/min, hot-row locks 144 writes/min — 4,800× fewer
Twelve instances flushing six times a minute produce 144 writes regardless of traffic. The counter becomes eventually consistent and can lose ten seconds of clicks on a crash — for a click counter, that is obviously the right price.

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

clients browser, app CDN / anycast edge L7 load balancer redirect service stateless, read-only Redis cluster 20 GB, ~95% hit KV store, sharded on hash(short_key) create service writes only key service 1 M-id blocks ~5% miss reads: 11.6k/s average, 35k/s peak — cache absorbs 95% writes: 116/s average, 350/s peak — three orders of magnitude smaller so the two paths are separate services that scale independently
The redirect service is deliberately the dumbest component in the diagram: one cache lookup, one 302, no writes, no auth, no joins. Everything expensive has been moved off the path that runs a billion times a day.

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.

Why this design is easy, in one sentence Once created, a short link is immutable — the mapping never changes. 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 at all. Say this, and the interviewer knows you understand why it's easy rather than just that it is.

Minute 30-45: the scaling path and the follow-ups

StageTriggerWhat changesWhat it buys
1. One boxLaunchApp + Postgres + Redis on one machineGenuinely serves millions of redirects a day. Start here and say so
2. Split and scale the read tierCPU on the appStateless redirect instances behind an L7 LB, dedicated RedisLinear read scaling; the DB is now protected by a 95% hit rate
3. Read replicasCache misses saturate the primaryRoute redirect misses to replicas; creates stay on the primaryRead capacity without sharding. Replica lag is harmless — the row is immutable
4. Shard the storeData past a few TB, or write IOPSHash-shard on short_key; or move to DynamoDB/Cassandra, which is what this access pattern wantsHorizontal storage and write scaling with no cross-shard queries, ever
5. Go multi-regionp99 for distant users, or a regional outage requirementRead replicas or a full cache per region; writes still home to one region; edge caching on the 302 itselfRedirect latency from ~120 ms to ~20 ms globally. Safe precisely because the mapping is immutable
6. Split analytics offAnyone asks for dashboardsClick events to Kafka, stream aggregation, columnar store for queriesAnalytics 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 a DROP 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.
←previousAPIs & communication↑ CovernextLoad balancing in depth→