Hotshard

Design a URL Shortener

Turn a long web address into a short one, send anyone who opens the short link to the original page, and count the clicks.

Predict ~1.5 h · Read ~45 min

The problem i

A URL shortener turns a long web address into a short one. You paste in something long, and you get back a short link — the kind you have seen as bit.ly/xY7q or similar. When someone opens that short link, they end up at the original page. That is the whole product from the outside: a long link goes in, a short link comes out, and the short link takes visitors where they were meant to go.

It sounds small, but two things make it worth designing carefully. First, a short link has to keep working — for years, for everyone, and without a noticeable pause — because people put these links in tweets, slides and printed posters, where they can never be edited again. Second, far more people OPEN short links than CREATE them: a link is made once and then opened over and over. So the system spends almost all of its time doing one job — taking a short code and sending the visitor to the right page. Making that fast, and never losing a link, is where most of the work is.

There is one more job. The people who make short links want to know how they are doing — how many clicks a link got, and roughly from where. So every time a link is opened, we also record that click. That gives us three jobs in total: make a short link, send visitors to the right place, and count the clicks. At the scale we design for, peak traffic is about 40k opens a second, against only about 400 new links a second — one hundred reads for every write. Keep that ratio in mind; it decides more of this design than any technology choice will.

To stay focused, we leave out the parts that do not change the shape of the design — user accounts, custom-branded domains, and scanning links for spam. They are real features, but you can add them later without redrawing anything here. Our time goes to the parts that matter: creating codes, redirecting quickly, storing links safely, and handling the flood of clicks.

Functional requirements what it must do

  • Create a short code for a long URL.
  • Send a visitor from a short code to its long URL.
  • Count the clicks on each link.
  • Keep every link working for years.

Non-functional requirements what it must be

  • Redirects feel instant.
  • The service stays up.
  • Click counts can lag, but never lie.

Out of scope left out on purpose

  • Accounts, login, custom domains
  • Spam and malware scanning
  • QR codes

How the design works

The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.

The API

The whole product is these endpoints. Every box in the design below exists to serve one of them.

POST /linksThe body carries the long URL; the response carries the new short code. The write path — 400/s at peak.
GET /{code}Answers a 302 redirect pointing at the long URL. The read path and the real workload — 40k/s at peak.
GET /links/{code}/statsThe click count so far, read from the click store the pipeline fills. Counts may lag by seconds; they never lie.

part 1 The single-server version

Start with the smallest thing that works: one application process and one database on one machine.

First, the words. When you shorten a URL, the system hands back something like sho.rt/3xY9zA1. The part after the domain — 3xY9zA1 — is the short code: a few characters that stand in for the whole long URL. The code is the product. Everything this system does is either handing out a new code or answering what an existing code points to.

Creating a link means the app server generates a fresh short code — a code that did not exist before — and writes one row to the database: the code, and the long URL it stands for. How to generate codes so that no two links ever share one is the first real decision of this design; the next section covers it fully.

Opening a link runs the other way: the browser sends a code, the app server looks up its row and answers a redirect — an HTTP response that says “the page you want is over there,” which the browser follows automatically. The visitor never sees any of this; they clicked a link in a tweet and landed on a page.

table urls — the day-one data model, one row per link
short_codechar(7)primary key — the code in the link
long_urltextwhere the redirect points
created_attimestampwhen the link was made
expires_attimestamp?optional expiry — empty means forever
CLIENTAPPDATABrowserapp server×1database
The whole system on day one.
Following a request
Create link
  1. Browser sends the long URL to the app server — this hop crosses the public internet, so it costs tens of milliseconds and distance decides how manytens of ms
  2. app server generates a new short code, then writes the code → URL row to the database — a durable write of a few milliseconds~10 ms
Redirect
  1. Browser sends the short code — the same public-internet hop, tens of millisecondstens of ms
  2. app server looks the code up in the database — an indexed read, single-digit milliseconds~5 ms

One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.

At launch traffic — about 50 opens a second, a new link every second or so — this machine has almost nothing to do. Even a year of links at this rate fits in a few gigabytes. Every piece of the design so far is correct.

It just doesn't survive success, and it fails in three separate ways. The read load grows with every link ever created — links accumulate, and old links keep getting clicked — until the database can't answer fast enough. Everything lives on one machine, so one bad disk takes every link ever made down with it. And the click counting doesn't exist yet at all. The next section deals with all three.

part 2 The version that survives scale

The product caught on, and the analytics feature shipped. Peak traffic is now 40k redirects a second — an assumed service about four times Bitly's published click volume, bigger than Bitly on purpose — against about 400 new links a second. Every redirect also records a click, so a 40k-a-second event stream now exists whether we designed for it or not.

Two things are breaking at once. Every redirect still reads from a single database built for a quarter of that traffic, and the analytics feature shipped wired the simplest way — the app fires each click event at a store that keeps only what it can handle and drops the rest.

The machine also stopped being one machine. At this volume the app tier is several stateless app servers behind a load balancer that spreads requests across them. The balancer is commodity infrastructure — a redundant pair that handles tens of thousands of connections a second inside one box — so it needs no decision of its own; it just has to be there, and it must not be the single thing that can die (the last use case comes back to that). Everything behind it is what still needs deciding.

This section walks the design by use case, in the order a request meets it, and makes every decision where its use case needs it: creating a link, storing the links, opening a link, counting the clicks — and then we try to kill it. The finished diagram waits at the end of the section; by the time you reach it, you will have already built it in your head.

Two ways through this section

The six decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.

use case 1 Creating a link

The flow is short. The browser sends the long URL — tens of milliseconds over the public internet. The balancer hands it to any app server; they are stateless, so any one will do. The app server generates a fresh short code, writes the row to the store, and returns the short link. Total time inside the building: about ten milliseconds. The only step that needs real thought is generating that code.

deep dive 1 Key generation
The question

Every new link needs a code that no other link already uses — 400 of them a second at peak, and the plan says 15B links after five years. Whatever generates them must make a duplicate impossible, not just unlikely: if two links share a code, one visitor silently lands on the wrong page, and no error shows up anywhere.

Before choosing how to generate codes, choose how long the code is, because length sets the size of the space we pick from. Codes are written in base62: counting with sixty-two symbols instead of ten — the digits 0–9, then a–z, then A–Z — which packs the largest number into the fewest URL-safe characters. Six characters give 62⁶ ≈ 57B possible codes: the plan fits, but the space is already 26% full by year five — and for randomly generated codes, that also means roughly one in four new codes would hit an existing one by then. Seven characters give 62⁷ ≈ 3.5T codes, and the same plan fills less than half a percent of it. One extra character removes the problem entirely, so the code is seven characters.

The tempting one is to hash the long URL and keep seven characters of the result. The same URL always produces the same code, so someone submitting the same URL twice gets the same link for free, and there is nothing to coordinate. This is where most engineers start, and at full hash length it is a fine idea. The problem hides in “keep seven characters.” A full hash almost never collides, but a truncated one does — and the birthday bound says how soon. In a space of 3.5T, random values start colliding at even odds around the square root of the space: about 2M codes, not trillions. We plan for 15B links, so truncated-hash collisions are not a risk, they are a certainty — and every one of them quietly sends someone's visitors to someone else's page, with no error anywhere. Free deduplication is not worth an undetectable wrong-page bug. Hashing is out.

That leaves two live options, and the real choice between them is simple: pay a small coordination cost once, or pay an existence check on every create, forever.

Random codes generate seven random base62 characters. Any app server does this alone — nothing shared, nothing coordinated — and the codes can't be guessed, so nobody can walk through your link space. But random means collisions are only unlikely, not impossible, so every create must first ask the store “is this code taken?” — a read on your busiest write path — and retry in the rare case the answer is yes. You can make that check nearly free with a bloom filter, but it never goes away, and past 2M links the retry path has to actually work.

A counter keeps one number that goes up by one for every link ever created; a link's code is that number, written in base62. This makes uniqueness automatic — the counter never produces the same number twice, so no two links can ever share a code, and nothing needs to be checked. There is no existence read on the write path at all. The cost is that a counter is one shared thing that some service has to own, and that codes handed out in order are guessable: from one code, anyone can guess the next code exists and walk your links one by one.

Counter → base62. When one option's cost can be engineered away and the other's is permanent, take the first. The random option's existence check is a read 400 times a second on the write path, forever, bloom filter or not. The counter's coordination cost, as the mechanics below show, can be made almost zero — and its guessability is fixed by scrambling the number before it becomes a code. Uniqueness stops being a probability and becomes arithmetic: no check, no retry, no read on the write path. And the space lasts: 3.5T codes at 400 creates a second — the peak, held forever, which real traffic never does — runs out in roughly 300 years.

Under the hood — how it works

A counter is just a number. The first link ever is number one, the next is number two, and link number one billion gets the code 15FTGg — that is one billion written in base62, six characters (shorter numbers are padded with leading zeros so every code is seven characters long).

The counter itself is one small shared sequence — a database sequence, or a tiny counter service. It is the only shared thing in this design's write path, so it is worth being precise about how it is used. App servers do not ask it for every link. Each server asks once for a block of 100k numbers, then hands them out one by one from memory, with no network call at all, and asks for the next block when the current one runs out. At 400 creates a second spread over four servers, one block lasts a server about 20 minutes — so the shared sequence is contacted a few times an hour instead of 400 times a second. That is why the coordination cost is close to zero in practice.

What happens when a server crashes in the middle of a block? The numbers it never used are simply never used by anyone. That is fine: codes have to be unique, not consecutive, and a gap of a hundred thousand numbers costs nothing in a space of 3.5T.

One follow-up is worth raising yourself: custom aliases. Real shorteners let a paying user pick the code — sho.rt/summer-sale instead of a generated string. This does not change the generator; it adds a second, much smaller path beside it. A chosen name has no counter behind it, so it needs exactly what the random option needed — check the store, refuse if the name is taken. That check was too expensive at 400 generated codes a second, but people rarely pick custom names, so here the check is cheap. Two details make it real: keep a reserved list (www, api, admin, and every path this product itself serves), and keep the two kinds of code from ever fighting over a name — generated codes are always seven characters, so requiring aliases to be any length except seven settles it for free.

Prep material often teaches this decision in a different shape: a key generation service, KGS for short. The idea is to make codes ahead of time — a separate service fills a table with unused random codes, and when someone creates a link, a code is taken from that table and marked used. It works, and it deserves a straight answer rather than a shrug: it solves the same problem as the counter's blocks, with more machinery. The table of codes is one more store to run, back up, and fail over; two app servers must never grab the same code, which is the same coordination the counter already solved; and the service itself is one more thing that can die on the write path. Notice that the block lease above is already pre-generation — a leased block is a batch of promised codes — just without the second database. If an interviewer asks for KGS, describe it, then say why the block lease buys the same guarantee cheaper.

⚡ From production

There is a second, less obvious problem with serving codes in counter order: the codes themselves reveal your company's numbers. Anyone can create one link today and another tomorrow, then subtract the two code values — the difference is exactly how many links everyone created in between. That is your growth chart, readable from the outside. (Estimating production from serial numbers is an old trick; the well-known case is the German tank problem, where Allied statisticians estimated tank production from captured serial numbers.) So scramble the sequence before it becomes a code. The scramble must map every number to exactly one code and back — that is what format-preserving encryption does. Do not use a hash for this: two numbers can hash to the same code, which brings back the very collisions the counter was chosen to rule out.

The German tank problem — estimating output from serial numbers
CLIENTEDGEAPPDATABrowserLBapp server×4 · base62 ✓databaseclick store
The board after this deep dive — “Create link” traced, hop by hop. The numbered steps below walk the same path.
Create link
  1. Browser sends the long URL to the balancer — the public-internet hop, tens of milliseconds, set by distancetens of ms
  2. balancer hands it to any app node — they're stateless~1 ms
  3. app server generates a new short code, then writes the code → URL row to the database — a durable write~10 ms
use case 2 Storing the links

Every code the create flow hands out is a promise that a row exists somewhere. Before making the read path fast, decide where the mapping lives, what it costs to store, and what happens to its data when a machine dies — the decision everything else on this board leans on.

deep dive 2 Datastore
The question

Something has to store the mapping from each short code to its long URL — and before picking a store, work out its size from a single row up. One row is a seven-character code, a long URL of a couple hundred bytes, two timestamps, plus per-row and index overhead — call it 500 bytes in total. 15B rows after five years is about 8 TB. So: 8 TB, written 400 times a second, read one row at a time by its code, and every row must stay safe for years. (Reads are drowning this store right now — that is the next use case's problem, and its fix works the same whichever store sits here.) Which kind of store fits that shape?

table urls — the data under this deep dive
short_codechar(7)primary key — the code in the link
long_urltextwhere the redirect points
created_attimestampwhen the link was made
expires_attimestamp?optional expiry — empty means forever

The fastest-sounding answer is to serve everything from memory — an in-memory store like Redis as the primary. Every lookup is a memory access, and there is almost nothing to operate at the start. But look at what it means for this store: it holds the only copy of every promise the product ever made — 15B links that must keep working for years — and memory is designed to throw data away under pressure. The day memory pressure evicts a key, a link someone paid for stops working, and nothing reports it. Keeping all 8 TB in RAM to prevent that would cost more than the rest of this design combined. Memory is the right place for the popular links — but that is a cache in front of the store, which the next use case builds, not the store itself. In-memory-as-primary is out.

That leaves two real stores, and it is honestly a judgment call between them.

DynamoDB is a managed key-value store. “Code in, URL out” is literally its API, and throughput, replication, and operations are the provider's problem — the read explosion becomes someone else's migration to run. The cost is that you commit on day one to a partition key — the one field the store uses to split rows across its machines — and everything that is not a plain key lookup (listing a user's links, scanning by date, changing the schema) gets harder from then on.

Postgres is an ordinary relational database. Looking up one row by its primary key is the thing it does fastest, and 400 inserts a second doesn't move the needle. Its durability story — backups, restores, point-in-time recovery — has been solid for decades, which is exactly what a store whose job is keeping promises for years wants. The cost is that when load outgrows one node, scaling it is your job, not a provider's.

Postgres. The workload is single-node sized — 8 TB fits one machine's disk, 400 writes a second is nothing, and the reads will be absorbed by the next use case's cache whichever store sits behind it. When the numbers don't force a distributed store, the ordinary store that keeps every future option open — ad-hoc queries, listings, migrations — beats the one that demands a data-model commitment up front. Choose DynamoDB when the team, not the workload, is the constraint: paying a provider to run the store instead of running it yourself is a legitimate trade.

Under the hood — how it works

The schema is the urls table from day one, unchanged — short_code char(7) (the seven-character length was worked out at the key-generation decision), long_url, created_at, and an optional expires_at. The primary key on short_code is a B-tree index: finding one row among 15B takes a handful of page reads, which is why a lookup is answered in single-digit milliseconds.

Rows don't have to live forever: expires_at gives a link an optional expiry. It is enforced at read time — the redirect checks the timestamp and refuses expired links — while a slow background job deletes dead rows later. Nothing is ever deleted on the request path: expiry is cleanup work, and a live redirect must never wait for cleanup. Two details finish the story. What the visitor sees: answer an expired link with 410 Gone, not 404 — 410 says “this existed and is gone for good,” which is the truth, and search engines treat it as final instead of retrying. And when a cache later fronts this store (the next use case builds it), cap each cached entry's lifetime at the link's expiry, so a dead link can never keep living inside the cache.

Because this store holds the only copy of the mapping, it does not run alone — it runs as a primary and a standby. Every write goes to the primary. The standby continuously receives and replays the primary's write-ahead log — the append-only journal the database already keeps of every change — so the standby is always a complete copy, running slightly behind. (This is called streaming replication.) If the primary's machine dies, the standby is promoted and takes over. How far behind the standby is allowed to run is a real setting with real consequences — the production note below — and who does the promoting, exactly once, is the last decision in this design.

⚡ From production

Postgres replication is asynchronous by default: the primary confirms a write as soon as it has it locally, and sends the change to the standby a moment later. Think about what that means for this product: if the primary dies inside that moment, the last few confirmed writes exist nowhere. A customer was told “here is your link” about a row that no longer exists — a link that returns 404 from the day it was printed on a poster. Postgres has a setting for exactly this: synchronous replication, where a commit does not return until the standby confirms it has the change. It makes writes slower — at 40k writes a second that would be a real cost — but the urls table takes 400 writes a second, and a few extra milliseconds on creating a link is something no user will ever notice. So: synchronous commit for the link mapping, asynchronous for everything high-volume. The setting works per transaction, which is what makes splitting it like this practical.

PostgreSQL docs — warm standby, streaming and synchronous replication
use case 3 Opening a link

The read path, and the real workload. The browser sends the code, the balancer passes it to an app server, and the app server answers the redirect. Between those steps sits the decision that shapes this whole design: at 40k lookups a second, what answers them?

deep dive 3 Redirect path
The question

At peak, 40k redirects a second each need a lookup, and the store answers about 10k indexed lookups a second before it falls behind — the read path is overloaded four times over. Before picking a fix, look at the shape of the load, not just its size. It is not 40k different links a second. Link traffic is heavily skewed: a few links are popular right now, and a huge number are quiet. During a spike, a single viral link can be 70% of peak by itself. The right fix has to match that shape.

The reflex when a store runs out of capacity is to shard it — split the table across several nodes so each holds a part. That is the standard answer when data outgrows one machine, and this board really does shard a store two decisions from now. But it is the wrong tool here, and the reason is precise: sharding splits the data, and our problem is not the amount of data. The store fits on one machine — 8 TB after five years — and the load is a read spike concentrated on a few links. Every read of the viral link carries the same code, the same code always hashes to the same shard, so that one shard is still past its ceiling while the others sit idle. Split it six ways or sixty: you'd run a sharded system and still be overloaded on the one hot shard. Sharding is for a write or size problem, not a hot-key read spike.

A more honest fix is read replicas — full copies of the database, with reads spread across them. Five replicas bring each node's share of 40k/s comfortably under its ceiling, and there is no new kind of component, just more of the database you already run. It genuinely clears the number. But price it: every replica stores all 15B rows in order to serve a popular set that would fit in one cache node's memory, every lookup is still at database speed rather than memory speed, and a replica always runs slightly behind the primary — so a link created a moment ago may not exist on the replica yet, and its very first visitor, the one the creator is watching for, gets a 404. Replicas are the right tool when reads are spread evenly across the data. Ours concentrate in a small popular set.

That skew is the whole answer. The popular set is small, so a small cache — the popular links' answers held in memory, in Redis, in front of the store — absorbs about 90% of every redirect, and the store only sees the misses: a tenth of the traffic, comfortably inside its 10k/s ceiling. The same skew that broke the store now saves it. One cache node buys what five replicas would, and answers from memory instead of disk.

One question remains, and it is really “who answers repeat clicks — our servers or the visitor's browser?” Answer a redirect with 302, the temporary redirect, and every click comes back to us: we can re-point the link tomorrow, and we count every click today. Answer with 301, the permanent redirect, and browsers remember it themselves — repeat visitors skip us entirely, which is strictly less traffic at no extra cost. The 301 is cheaper and it works, but the word permanent is the catch: a destination you later need to change is stuck in caches you don't control, and every click a browser answers by itself is a click your servers never see. Because this product counts clicks, that undercount is disqualifying.

Cache the hot set — serve 302. A cache the size of the popular set does the work: it absorbs 90% of redirects from memory, the store sees only the misses, and answering 302 keeps every click coming back to us — countable today, re-pointable tomorrow.

Under the hood — how it works

Start with how big this cache really is. An entry is small — a seven-character code, a URL of a couple hundred bytes, some bookkeeping: about 300 bytes. Suppose we cache generously: the 100M most recently used links. That is roughly 30 GB — one cache node's RAM, standing in front of an 8 TB store. Those two numbers are the whole argument: the store is 250 times bigger than the memory needed to answer 90% of its traffic.

When the cache is full, it drops the entry that was used longest ago (this policy is called LRU — least recently used). Under skewed traffic the entry dropped is always some cold, rarely-used link — never the popular ones, because they keep getting used. Every entry also has a TTL (time to live) of about a day: after that it expires and the next request fetches a fresh copy, so no stale answer can live longer than a day even if everything else goes wrong.

Two situations need explicit handling. First, edits and deletes must not wait for the TTL: changing or deleting a link removes its cache entry immediately, because a link that turns out to point at malware has to stop working now, not within a day. Second, when a viral link's cache entry expires in the middle of its spike, thousands of requests miss at the same moment, and each of them would ask the store for the same row. Here is how only one of them actually reaches the store: the first request to miss writes a marker into the cache under that key, meaning “a fetch for this row is already running.” Every later request that misses the same key sees the marker and waits for that fetch instead of starting its own. When the fetch returns, it puts the row in the cache, and all the waiting requests read it from there. A thousand simultaneous misses become one database read and a thousand cache reads. (This pattern is called single-flight.)

One question a strong interviewer asks here: everything inside the building answers in a few milliseconds, but the visitor's hop across the public internet costs tens of milliseconds — and that hop is still on every redirect. Why not answer from the edge, the way the biggest shorteners do? The technique is real: copy the code → URL mapping to CDN servers in hundreds of cities, and a click in Sydney is answered in Sydney instead of crossing an ocean. Now price it. The mapping lives in hundreds of places, so changing or deleting a link means invalidating every copy — the same staleness problem the cache just introduced, multiplied by every city. And every click still has to travel back to our servers to be counted, so the counting job gets no faster. Here the budget of 100 ms is comfortably met from one region, so the honest answer is: name the edge, price it, and set it aside until the product is global enough to need it.

⚡ From production

The HTTP spec makes a 301 cacheable by default and a 302 not. So a 301 doesn't just sit in browsers — CDNs and proxies keep it too, with no expiry you control, replaying it for as long as they like. "Permanent" here means permanent in machines you cannot reach, not just in your own store — which is the real reason a product that ever needs to re-point a link or count a click cannot afford it.

RFC 7231 §6.1 — 301 is cacheable by default, 302 is not
CLIENTEDGEAPPDATABrowserLBapp server×4 · base62 ✓postgresurlsclick storecache · redisLRU · TTL 24h
The board after this deep dive — “Redirect” traced, hop by hop. The numbered steps below walk the same path.
Redirect
  1. Browser sends the short code to the balancer — the public-internet hop again; it dominates everything belowtens of ms
  2. balancer round-robins it to an app node~1 ms
  3. app server checks the cache first — most redirects end here; misses fall through to the store~1 ms
use case 4 Counting the clicks

The third job. Every redirect records a click event, and creators read per-link counts from it. Two decisions live here: how a click gets from the redirect to storage without slowing anyone down, and how a store takes 40k writes a second without melting. Wiring first — it decides what the store even receives.

deep dive 4 Click pipeline
The question

Right now every redirect writes its click straight to the store and moves on. When the store has a bad moment — a timeout, a deploy — those clicks are simply lost, and the counts creators see are wrong. Fix the counts without slowing the redirect: the time budget is 100 ms per redirect, the promise is exact counts, and the volume is one event per redirect — 40k a second at peak, indefinitely.

The instinct that stops a dropping counter in most codebases is to make the insert synchronous — the redirect isn't done until the click row is written. Now the counts can't be wrong, and the change is one line. But it ties your fastest path to your slowest component: every redirect now waits for the store, including during the store's worst moments. A store at its limit doesn't answer in its usual few milliseconds — it answers when its queue of waiting writes clears, which near the limit is about a full second. The whole redirect budget is 100 ms, so one synchronous write spends it ten times over. The honest instinct, pointed at the wrong victim.

The other tempting shortcut is to sample — store one click in ten and multiply. A tenth of the stream fits anywhere, and sampling is genuinely how the web's biggest analytics products survive their own volume. But it answers a cost problem we don't have, and it breaks a promise we do have. A creator whose link got a few dozen clicks would see an estimate that changes on every refresh — off by tens, noise where they wanted a number. The requirements let counts lag; they never let counts be wrong, and sampled counts are wrong at exactly the scale our paying users live at.

The shape every serious event pipeline lands on is a queue — a durable, append-only log (Kafka) between the redirect and the store, drained by a pool of workers. The redirect appends the click to the log and gets an acknowledgment in under a millisecond, then finishes; a separate set of workers reads the log and writes to the store in batches, at the store's own pace. The intake is never the worry: a single Kafka-class log broker sustains around 100k appends a second, and our 40k/s stream runs at under half of that. Nothing is lost, and counts run a few seconds behind.

A Kafka queue, drained by ingest workers. Separate the two paths: the redirect pays one sub-millisecond acknowledged append to a durable log and finishes, and the ingest workers drain that log into the store in batches at the store's own pace. Loss turns into lag — the one thing the requirements allow. Be clear about what the log buys, though: honesty, not throughput. Whether the store can actually drain what arrives is a separate question about its own ceiling — the next decision on this board.

Under the hood — how it works

The app server writes one small event — code, creator, timestamp, country — to the log and gets an acknowledgment in under a millisecond. From that instant the click is safe on disk, even though it hasn't been counted yet. The ingest workers read the log in batches and update the click store a few seconds behind.

One detail matters: a durable queue re-sends anything it is not sure was processed (this is called at-least-once delivery), so the same click can arrive twice — and a count that includes a click twice is wrong in the other direction. The fix: the app gives every event an id when it is created, and the workers write by id, so a click that arrives twice is stored once. The backlog is allowed to grow; the count is not allowed to drift.

The queue is the click path's safety, so it can't be a single box: Kafka replicates each partition across several brokers, so a broker dying loses nothing that was already acknowledged. An append that returned to the redirect is on more than one machine before the visitor's page finishes loading.

⚡ From production

The id-based dedup has a limit worth knowing about. Checking "have I seen this id before?" only works while the earlier id is still stored, and you cannot store ids forever — that store would grow faster than the counts it protects. So you keep a window, say one day. A duplicate that arrives inside the window is caught; one that arrives later is counted twice. "Exact counts" therefore honestly means: exact within the window, very slightly off outside it. That is fine for almost every creator — but the product page says exact, so this detail belongs somewhere a paying customer can read it.

Confluent — Kafka delivery semantics (at-least-once by default)
deep dive 5 Shard key
The question

Now the store the ingest workers write into. Size it first: a click event is small — code, creator id, timestamp, country — about 100 bytes, but there are 40k of them a second at peak. At an average of a quarter of peak, that is roughly 90 GB of new data a day, about 30 TB a year. The write rate is the harder limit: one Cassandra node takes about 10k writes a second, and the stream peaks at 40k. No single node can take this, so the store has to be split across several: 40k ÷ 10k = 4 nodes minimum; use 6 for headroom. Splitting one table across nodes is called sharding — each node (each shard) owns a part of the rows, and a rule decides which shard every row goes to. The rule is the decision: which column carries the split? Ask two things of each candidate — when a click lands, which value decides its shard, and can that value pile up on one shard?

table click_events — the data under this deep dive
short_codechar(7)which link was clicked
creator_iduuidwho owns the link
created_attimestampwhen it landed
countrychar(2)payload — rides along

Split by time — created_at — fails immediately. Every click happening now carries the same hour, so the newest shard takes the entire 40k/s while the others do nothing. Old data would drop a whole shard at a time, which is genuinely nice, and a day's range scan would stay on one shard — but “now” is always a single bucket, and a live write stream can't have a permanent hot spot. Time-based splits are for reading history, not for absorbing a live stream.

Split by owner — creator_id — is more tempting: a creator's whole dashboard reads from one shard, and each account's data lives in one place. It fails on real traffic rather than in theory. Write volume follows virality, and virality concentrates on a few creators. Run our own last hour of traffic through this split and the two busiest creators happen to land on the same shard, which then carries 61% of all writes while the others sit idle. There is no way to rebalance out of it, because the key itself concentrates the load. Great for reads, wrong for a write-heavy stream.

Split by the link — short_code — is the one that holds. Every event already carries its code, so working out the shard is one hash calculation, no lookup anywhere. Dozens of links are active in any given hour, so hashing the code spreads writes flat across the shards. And one link's count conveniently lives on exactly one shard, so reading a single link's total is one shard's job. The cost is that one creator's links end up spread across several shards, so a per-creator dashboard collects from several places — but the ingest workers already add those per-creator numbers up as they drain the queue, a small price for a store with no hot spot.

short_code. Split by the value every click already carries and no single account can pile up: the route is one hash with no lookup, dozens of active links share each hour so the writes spread flat, and one link's count lives on exactly one shard.

Under the hood — how it works

Routing is arithmetic done by the ingest workers as they write: hash the code, map the hash to a shard, append. There is no router machine and no lookup table — every writer computes the same answer from the key itself.

The map from hash to shard is the part with a future. If it were simply hash-mod-6, then on the day 6 shards stop being enough, adding one more would change almost every key's answer at once — nearly every row suddenly on the “wrong” shard, and moving them mid-traffic is a mess. So the hash is placed on a ring instead (consistent hashing): each shard owns a section of the ring, a new shard takes over part of its neighbours' sections, and only about one shard's worth of keys ever has to move. This ring placement is exactly how Cassandra spreads a table across its nodes — which is why naming Cassandra here isn't decoration: the store we picked already does the thing this decision needs.

A shard that dies must not take its slice of the clicks with it, so each shard is replicated: Cassandra keeps every row on more than one node (replication factor at least two), placed around the ring, so a single node's death loses no clicks and the workers keep writing to the surviving replicas.

Retention is part of the design too. Raw events are dropped after a window — the per-link counts, which the workers keep up to date as they drain, are the product — so 30 TB a year of raw events never becomes a permanent bill.

⚡ From production

short_code spreads the stream as a whole; it cannot spread one link. When a single code takes 70% of peak, every one of its writes carries the same key, the same key always hashes to the same shard, and adding shards changes nothing about that. The production fix is to give that one hot code several storage keys: append a short random suffix — code-a, code-b, code-c — so its writes spread across shards again, and have the dashboard add the parts back together when it reads. This works, but be clear about the cost: the property that made short_code win — one link, one shard, one row to read — is exactly what you give up for the hottest links.

AWS — sharding a hot partition key with a random suffix
CLIENTEDGEAPPPIPELINEDATABrowserLBapp server×4 · base62 ✓postgresurlsclick store · cassandra6 shards · by short_codecache · redisLRU · TTL 24hclick queue· kafkaingest workers
The board after this deep dive — “Click event” traced, hop by hop. The numbered steps below walk the same path.
Click event
  1. app server appends the click to the durable queue — one acknowledged write, well under a millisecond~1 ms
  2. ingest workers pull batches off the queue at their own pace~1 ms
  3. the workers write each batch into the click store — seconds behind, never wrongseconds
use case 5 Surviving a failure

Every box on this board is now doing its job. That is exactly the moment to ask the hard question: what happens when one of them stops?

deep dive 6 Single point of failure
The question

The board is built. Before trusting it, try to kill it: one part of this design, failing on its own, takes every redirect down with it. Which part is it — the one you cannot afford to lose?

Kill each one and trace a redirect. Kill the balancer: dead at the first hop — nothing downstream even hears the click. Real and total, but also the failure everyone already plans for; balancer pairs are standard, solved infrastructure. Kill Postgres: the trace shows something surprising — the redirect path no longer goes through it. The cache answers 90% of clicks without asking the store; only cache misses and brand-new links suffer until the standby takes over. A bad hour and real damage — but not the box that takes every redirect down. Kill the cache: with no cache there is no absorption, and the full 40k/s lands on a store built for 10k/s. The path doesn't fail cleanly — the store is instantly buried under four times its capacity, and everything behind the cache goes down with it.

The cache. The cache stopped being an optimization the day it started answering 90% of the reads — whatever absorbs the traffic IS the path. Give it a live twin, pair the balancer in front of everything, and keep the store's standby. Protection should follow the traffic, not the org chart.

Under the hood — how it works

A spare is only as good as the switchover to it, so state how each one takes over. App servers and the cache twin sit behind health checks: a node that stops answering its heartbeat is removed from rotation within seconds, with no human involved. The balancer pair shares one address, set up so that only one of them is ever in charge at a time.

The store's standby is promoted exactly once, by one decider — because if a confused network ever let both copies believe they were the primary, both would accept writes, and you would have two different histories of the truth (this is called split brain). That is strictly worse than the outage you were protecting against: an outage ends, but diverged data has to be untangled row by row. Promoting exactly one standby while the network is misbehaving is a genuinely hard algorithmic problem — consensus — and it is the one mechanism on this board handed to a dedicated tool rather than built by hand.

Notice this sweep covers the read path only, and that is deliberate: the write path already hardened itself as it was built. The Kafka queue replicates every append across brokers, and the Cassandra shards each keep a replica around the ring — so a broker or a shard dying loses no clicks. The ingest workers are stateless drainers behind the same health checks as the app servers. The click path was made redundant one decision at a time; the read path is what this final sweep had left to protect.

Where this design sits on consistency (and CAP)

Across the six decisions, we quietly answered the same question several times: when parts of this system briefly disagree, who is allowed to notice? A cached redirect can serve an edited link's old target until the invalidation lands. A brand-new link must never 404 — its creator is watching its very first click — which is why the create waits for the standby's confirmation, and why the replica option routed fresh codes to the primary: the same pattern, read-your-writes, in two places. Click counts run behind but are never wrong. And on failover there is exactly one primary, because two primaries writing at once is worse than an outage.

There is a name for the underlying choice. When a network fault splits a system into halves — and at this scale, one day it will — every operation has exactly two honest options: answer anyway from what this half knows (stay available, risk staleness), or refuse to answer until the halves can agree (stay consistent, become unavailable). That either/or is the CAP theorem, and the useful way to apply it is not “pick a letter for your database” but: decide per operation, based on what each operation's wrong answer would cost.

Read this design that way. The redirect answers anyway — from cache, even slightly stale — because a stale target for a few seconds is a nuisance, while a dead link on a million pages is the product failing. The create refuses — no code is handed out without a durable, replicated row — because a code that 404s from birth is a promise already broken. The counts answer with whatever the pipeline has drained so far — the requirements allow exactly that. If an interviewer asks “is this system AP or CP?”, the strongest true answer is: the redirect path is AP, the create path is CP, and the split is deliberate.

CAP theorem — the partition trade-off, per operation
The finished board

This is the board you just built — every box on it put there by one of the six decisions above.

CLIENTEDGEAPPPIPELINEDATABrowserLBapp server×4 · base62 ✓postgresurlsclick store · cassandra6 shards · by short_codecache · redisLRU · TTL 24hclick queue· kafkaingest workers
The finished design — every box placed by one of the deep dives above.
What's on the diagram
Browser
The visitor's browser. It sends the short link and follows wherever the redirect points.
LB
Load balancer. Spreads incoming requests across the app servers so no single one is overloaded.
app server
App servers. They create short codes and look up where each code points. They keep no data of their own, so you can run as many as the traffic needs.
postgres
Database. The source of truth — the permanent mapping from each short code to its long URL.
click store · cassandra
Click store. Records one row per click so creators can see their numbers. It is split into shards by short_code to spread the write load.
cache · redis
Cache. Keeps the most-clicked links in memory, so the large majority of redirects are answered without touching the database.
click queue · kafka
Queue. A durable buffer for click events: the redirect drops an event here in well under a millisecond and moves on, and the store empties the queue at its own pace.
ingest workers
Ingest workers. Drain the click queue and write the events into the click store at the store's own pace, so a burst of clicks never backs up onto the redirect path.
What this design never covered — raise it yourself
Rate-limiting the shorten API

The shorten endpoint is a write path anyone can call, and anything that mints database rows for free will eventually be scripted: one loop can flood the store with junk rows and turn the service into a spam-link factory. The strong answer takes one sentence — limit creates per caller, by API key or IP, at the front door before requests reach the app servers — and one clarification: the redirect path stays unlimited, because absorbing real traffic is its whole job. If pushed for a mechanism, name a token bucket — tokens refill at a fixed rate, each request spends one — and move on; nothing on this board changes.

Go deeper — the primitives this design leaned on
  • Cachekeeps the hot links in memory so a redirect never waits on the store
  • Bloom filteranswers “is this code already taken?” in memory, so most checks skip the store
  • B-treethe index that finds a long URL from its short code
  • Write-ahead logmakes each click durable the instant it lands, before it is counted
  • Consistent hashingadds a shard without reshuffling every key
  • Raftpromotes a standby cleanly when the primary dies

Feedback on this problem →