Skip to content

The classic designs

URL shortener, rate limiter, ticket booking, file sync, distributed cache, notifications. The six that come up most.

01

URL shortener: the interview question that is actually about ids

beginner

The apparent problem is mapping a short code to a long URL, which is a hash table and would be a dull question. The real problem is generating the codes, and that is where every interesting decision lives.

Hashing the URL is the first instinct and it is wrong in a specific way. A hash truncated short enough to be a usable code will collide, so you need collision handling, and the same URL submitted twice produces the same code, which sounds like a feature until someone wants two codes with different analytics or different expiry.

A counter is better than it sounds. Take an incrementing number, encode it in base 62 using digits and both cases of the alphabet, and you have short codes with no collisions by construction. Seven characters of base 62 is about 3.5 trillion combinations, which is more than enough. The objection is that codes are sequential and therefore guessable, and the objection is correct: anyone can enumerate every link ever created. If links are ever private, this alone disqualifies it.

The usual answer is to keep the counter and break the sequence. Give each server a block of the range to hand out, so they do not coordinate on every request, and either encode the number through a reversible permutation or mix in random characters so consecutive ids do not produce consecutive codes.

Then the read path, which is where the traffic actually is. Reads outnumber writes by orders of magnitude and the mapping never changes once created, which is the ideal shape for caching. A cache in front of the store serves almost everything, and the redirect itself should be an HTTP 301 or 302 chosen deliberately: 301 is permanent and browsers cache it aggressively, which is fast and means you stop seeing the traffic, so anyone who wants click analytics must use 302 and accept the load.

Check yourself

Why does the choice between a 301 and a 302 redirect matter here?

shortennext idwrite oncefollow linkon missClientShortener APIId allocatorblocks per serverCachecode to URLMapping storeimmutable rowsClick eventsif using 302
ClientServiceData storeQueue / streamDashed = asynchronous
Writes allocate an id, reads are almost all cache
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

02

Rate limiter: four algorithms and the one you should reach for

intermediate

A rate limiter answers whether this caller may make this request now, and the algorithm chosen determines exactly how it behaves at the edges, which is where all the complaints come from.

A fixed window counts requests per calendar window: a hundred per minute, reset on the minute. It is trivial and it has an obvious flaw. A caller making a hundred requests at 10:00:59 and a hundred more at 10:01:00 has made two hundred in one second while never exceeding the stated limit. The boundary is a hole.

A sliding window log fixes it exactly by storing a timestamp per request and counting those within the last minute. It is precise, and it costs memory proportional to the request rate per caller, which for a busy API is a great deal of memory to spend on bookkeeping.

A sliding window counter is the usual compromise: keep the current and previous window counts and weight the previous one by how far into the current window you are. It approximates the sliding log closely, with two integers instead of a list.

A token bucket is different in kind and is usually the right answer. Tokens are added at a fixed rate up to a maximum, and each request consumes one. The rate sets the sustained throughput and the bucket size sets how large a burst is tolerated, which means bursts are permitted deliberately rather than as an artefact. Real traffic is bursty, and a limiter that forbids bursts entirely rejects requests that the system could comfortably have served.

Two implementation details matter more than the algorithm. Distributed limiters need shared state, and a strictly consistent counter puts a coordination round trip on every request; most systems accept approximate limits with eventually consistent state, because the goal is protecting the service rather than perfect accounting. And the limiter should tell the caller what happened: a 429 with RateLimit headers and Retry-After lets a well-behaved client back off correctly instead of guessing.

Check yourself

Why is a token bucket usually preferred over a sliding window counter?

token availableeventually consistentallowedempty bucketCallerLimitertake a tokenBucketcapacity, refill rateShared stateapproximateServiceif a token was taken429 with headerslimit, remaining,reset
ClientEdge / CDNData storeServiceDashed = asynchronous
Token bucket: rate sets throughput, size sets the burst
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

03

Ticket booking: the seat that two people want

advanced

Selling a finite inventory of unique items to concurrent buyers is one of the few genuinely hard consistency problems that appears in ordinary products. Two people click the same seat within milliseconds and exactly one must get it. There is no eventual consistency answer that is acceptable, because the failure is selling one seat twice.

Optimistic concurrency is usually the right mechanism. Each seat carries a version, and the update says set this seat to sold where the version is still what I read. One update matches and succeeds; the other matches nothing and fails, and its buyer is told the seat has gone. No locks are held while a human decides, which matters because humans are slow and a lock held across a checkout flow is a lock held for minutes.

The reservation window is where the design gets interesting, because a purchase is not instant. A buyer needs time to enter payment details, so the seat is held. Holding it forever means abandoned baskets consume the inventory; not holding it means someone completes payment for a seat that has just gone, which is far worse. So holds carry an expiry, typically a few minutes, and expired holds are released. The expiry must be enforced by the system rather than by the client, because a browser that closed will never tell you anything.

The queue is what makes a high-demand sale survivable. Ten thousand people arriving for a hundred seats will collapse a system that lets all of them into the checkout, and the answer is a virtual waiting room that admits people at the rate the inventory system can actually serve. It converts an unbounded spike into a controlled flow, and it is more honest with the customer than a page that fails.

Payment failure is the last piece and needs care. A hold succeeds, payment fails, the seat must return to inventory. But payment failure is sometimes ambiguous, as a timeout tells you nothing about whether the charge succeeded, so the resolution needs an idempotency key and a reconciliation step rather than an immediate guess.

Check yourself

What does a virtual waiting room actually solve?

admittedconditional updatewith a keyrelease expiredBuyersa spikeWaiting roomadmits at a rateReservationexpires in minutesSeat inventoryversioned rowsPaymentidempotentExpiry sweeperreleases abandoned
ClientEdge / CDNServiceData storeExternalDashed = asynchronous
Admit at a controlled rate, hold with an expiry, commit optimistically
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

04

File sync: conflicts are not an edge case

advanced

Syncing files across devices sounds like copying and is not. The moment two devices can edit while offline, you have concurrent writes without coordination, and the interesting question is not how to move bytes but what to do when both changed.

The transfer itself has a well-known optimisation: split files into content-addressed chunks. A file becomes a list of chunk hashes, and syncing means transferring only the chunks the other side lacks. Change one paragraph in a large document and one chunk moves. Chunking also gives deduplication for free, since identical chunks anywhere are stored once, which is why uploading a file someone else already has can be nearly instant.

Detecting conflict needs causality rather than timestamps. Wall clocks on two devices disagree, and last-write-wins by timestamp silently discards edits when a clock is wrong. Version vectors record what each device had seen when it wrote, which makes it possible to distinguish a genuine concurrent edit from one device simply being behind.

Once detected, something must resolve it, and the honest options are limited. Automatic merge works for structured data with defined semantics and not for arbitrary binary files. Last-write-wins is simple and loses work. Keeping both versions, which is what most sync products do, is the only option that never destroys data, and it hands the problem to the person, which is where it belongs when the system genuinely cannot know.

Deletion is the subtle one. A file deleted on one device and edited on another is ambiguous, and worse, a deletion cannot simply be the absence of a file, because absence is indistinguishable from never having received it. Deletions must be recorded as explicit tombstones that propagate, and tombstones have to be retained long enough for every device to see them, including one that has been offline for months.

Check yourself

Why use version vectors rather than timestamps to detect conflicting edits?

missing chunks onlywhat each device sawconcurrent edit detectedboth versionsboth versionsDevice Aedits offlineDevice Bedits offlineChunkercontent addressedMetadataversion vectorsChunk storededuplicatedConflict handlingkeep both
ClientServiceData storeDashed = asynchronous
Chunk, compare, and decide what a conflict means
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

05

Distributed cache: hashing, stampedes and the hot key

advanced

A cache spread across many nodes needs an answer to which node holds a given key, and the naive answer breaks badly. Hashing the key modulo the node count means adding or removing one node changes the destination of almost every key at once, so a single node joining invalidates the entire cache and sends the full load to the origin.

Consistent hashing fixes this. Nodes and keys are placed on a ring, and a key belongs to the next node clockwise, so adding a node reassigns only the keys between it and its predecessor. Virtual nodes, several ring positions per physical node, are what make the distribution even, because a handful of random positions produces very uneven arcs.

The cache stampede is the failure that catches people. A popular key expires, and every request that wanted it simultaneously misses and goes to the origin, which now receives the full unmitigated load for that key at once. The fixes are to have one request recompute while others wait or serve stale, and to jitter expiry times so a batch of keys written together does not expire together.

A hot key is a different problem with a similar smell. Consistent hashing distributes keys evenly and says nothing about traffic, so one extremely popular key sends all of its traffic to one node however many nodes you have. The answers are local caching in front of the distributed one, or replicating that key deliberately across several nodes.

Eviction policy is the last decision and it is usually less important than people think, with one exception. Least-recently-used is the sensible default. The exception is a scan, where something walks a large number of keys once, evicting everything useful in favour of things that will never be read again. This is why some systems segment the cache so a scan cannot evict the working set.

Check yourself

Why is hashing a key modulo the node count a poor way to place cache keys?

on misssingle flight, others waitApplicationLocal cacheabsorbs hot keysConsistent hashvirtual nodesCache nodeCache nodeCache nodeOriginone recompute per key
ClientData storeServiceDashed = asynchronous
Ring placement, one recompute per key, replication for hot keys
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

06

Notifications: fan-out with a person on the other end

intermediate

A notification system takes an event and delivers it to people across channels: push, email, in-app, sometimes SMS. The infrastructure is a fan-out problem and the hard part is not the fan-out, it is deciding what deserves to be sent at all.

Structurally it is a pipeline. An event arrives, preferences are resolved to decide who wants what through which channel, messages are rendered, and each channel is dispatched through its own provider. Every stage can fail independently, and channels have wildly different characteristics: push is fast and unreliable, email is slow and durable, SMS costs real money per message.

So delivery has to be per-channel rather than per-notification. Retrying a push aggressively is fine; retrying an SMS aggressively costs money and irritates someone. Each channel needs its own retry policy, its own rate limits and its own failure handling, which is why treating them uniformly behind one abstraction usually goes wrong.

Deduplication and batching are what make the system tolerable to receive. Ten people commenting produces ten events and should produce one notification saying ten people commented. This is a windowing problem, and the window is a product decision rather than a technical one: too short and it fragments, too long and it arrives after it mattered.

Preferences must be enforced at the point of sending, not at the point of subscribing. People change their minds, unsubscribe, mute a thread, or turn off a category, and any of those can happen between an event being queued and it being delivered. Checking at queue time means a message sent minutes after someone unsubscribed, which is at best rude and, for marketing categories, potentially unlawful.

Finally, quiet hours and priority. A security alert should wake someone; a weekly digest should not. Without a priority model everything becomes urgent, and once everything is urgent people turn all of it off, which is the real failure mode of notification systems.

Check yourself

Why should each channel have its own retry policy?

before dispatchEventsomething happenedResolve preferenceswho, which channelDedupe and batchwindowedPushfast, unreliableEmailslow, durableSMScostly per messageSend-time checkstill subscribed
Queue / streamServiceExternalDashed = asynchronous
Resolve, batch, then dispatch per channel with its own rules
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.