System Design

Problem Breakdowns

URL Shortener

Generating the code is the easy part, and it is where most answers stop. The interesting system is everything that happens because a short link is a permanent public promise you can never take back.

Every candidate has prepared this one, and most of them prepared the wrong 20 minutes of it.

Ask someone to design a URL shortener and they will start generating short codes. Hash the URL and take a prefix. No wait, use a counter and base62 encode it. Handle collisions with a retry loop. 20 minutes later they have a very thorough answer to the least interesting question in the problem. And they have not said a word about the thing that actually eats engineering time at a real shortener.

Here is the shape of it. A URL shortener is really four separate systems that happen to share a name:

  1. A mapping from a short code to a long URL. Genuinely easy. It is a hash table: one key, one value, fetched by the key.
  2. A serving tier that answers hundreds of thousands of requests per second in under 20 milliseconds, from anywhere on earth. A caching problem, and a pleasant one, because the thing being cached can never go out of date.
  3. A click pipeline carrying 100 times the write volume of the mapping itself. Every redirect produces an event to record, and there are roughly 100 redirects for every link created. This is the part customers actually pay for and the hardest part to run.
  4. An abuse control system, because you have built a machine for hiding where a link goes behind a meaningless code on a domain the recipient already trusts. That is exactly what a phishing campaign needs.

The first one takes an afternoon. The other three are the job.

Establish this early in the interview, because it makes the rest of the design much easier to work through:

A mapping never changes once it is written.

Because the value behind a code can never be different tomorrow than it is today, you can cache it forever and never clear that cache. You can copy it to every edge server on the planet and never worry about one copy disagreeing with another, because there is no second version for them to disagree about. All the machinery that normally comes with distributed data, deciding which of two writes won, resolving conflicts between regions, telling every cache to drop an entry, exists to handle values that change. This one does not, so none of that machinery has to exist here.

Every genuinely hard problem in this design is a place where something breaks that promise. A custom alias that two people both want. A link that expires. A phishing takedown that has to reach every edge server within 90 seconds.

Design for a mapping that never changes first. Then spend your deep dives on the exceptions, because that is where nearly all the engineering time actually goes.


Understanding the problem

A user submits a long URL and gets back a short one on a short domain. Anyone who visits the short URL is sent on to the original. The owner can see how many people clicked.

That is the entire product, which is why the question is deceptively popular. Before you design anything, pin down four things that get mixed up:

  • Shortening versus link management. Shortening is one write and one row. Link management is a whole product sitting on top of it: branded domains, editing a live link's destination, grouping links by campaign, QR codes. One of those features, editing the destination of a link that is already out in the world, quietly destroys the promise that a mapping never changes. More on that later.
  • Clicks versus analytics. Recording a click is one append to a log. Analytics is a query engine sitting over a petabyte of those events, answering questions like "clicks by country last Tuesday" fast enough that a dashboard feels live. Those are two different systems with two different scaling stories.
  • Redirect uptime versus creation uptime. These deserve different targets and almost nobody separates them. A user who cannot create a link is annoyed and retries in a minute. A redirect that fails breaks a QR code already printed on 50,000 physical posters, and no amount of fixing your service later un-prints them.
  • Abuse control versus content moderation. You are not judging whether the destination is good or tasteful. You are deciding, within seconds, whether to keep pointing traffic at it.

Functional requirements

Core:

  1. Given a long URL, return a short URL. Optionally accept a user-chosen alias and an expiry time.
  2. Given a short code, send the visitor on to the original URL.
  3. Record every click and show the link's owner the totals.
  4. Turn a link off everywhere, on demand, fast.

Out of scope, deliberately:

  • Authentication and account management. Assume we know who the caller is.
  • Custom branded domains. Real feature, real work, but it is a TLS and routing problem rather than a shortening one.
  • QR codes. The same mapping with a different renderer in front of it.
  • Editing the destination of a live link. Name this one out loud and say why you are putting it off, because it directly contradicts the design's central assumption. It is also a real paid feature at every commercial shortener, so it is not a strawman.

Non-functional requirements

  1. Redirects finish in under 20 ms of server time at p99, worldwide. The visitor has already paid for DNS, TCP and TLS before you get involved. You are the last hop before the content they actually wanted.
  2. The redirect path is more available than the creation path. State that gap deliberately. Creation can be down for minutes. Redirects cannot be down at all, because the links are already printed on things.
  3. Scale: 100 million new links per day, 10 billion redirects per day, links kept for 10 years. Reads outnumber writes roughly 100 to 1.
  4. Codes are permanent, never reused, and not guessable. No code is ever handed out twice, not even after the link is deleted. Nobody can walk the whole space and collect the live ones.
  5. Click counts may be approximate and late. The mapping may not. Losing a fraction of a percent of click events during an incident is fine. Losing one mapping is not.

Requirements 4 and 5 carry the weight, and together they are the thesis. Write them down in those words.


The set up

Core entities

  • Link. The part that never changes: code, long_url, owner_id, created_at. Written once at creation and never touched again for the rest of its life.
  • Link state. A second record that sits beside the Link, holding disabled, expires_at and takedown_reason. These live in a separate record rather than as extra columns on the Link, on purpose, because they behave completely differently. The Link is written once and read billions of times. State can flip at any moment and has to take effect fast. Splitting them is not fussiness. It is the single decision that lets you cache the Link forever, with no expiry at all, and still switch a link off within seconds.
  • Click event. code, timestamp, country derived from the visitor's IP, user agent, referrer. Append only, very high volume, and worth nothing on its own. One person clicked a link at 14:32, so what. Ten million of them added up is the entire paid product.
  • Alias reservation. The ledger recording who claimed which user-chosen alias. This is the only place in the whole system that needs a real uniqueness guarantee, meaning a check that two simultaneous requests cannot both pass.

System interface

Two HTTP surfaces worth writing down, on two different hostnames. The dashboard sits on a third.

POST https://api.example.com/v1/links
{
  "long_url":   "https://shop.example.com/spring/sale?utm_source=newsletter",
  "alias":      "spring-sale",              // optional, 8 to 64 chars
  "expires_at": "2027-01-01T00:00:00Z"      // optional
}

201 Created
{
  "code":       "3xY9zKq",
  "short_url":  "https://exm.pl/3xY9zKq",
  "long_url":   "https://shop.example.com/spring/sale?utm_source=newsletter",
  "created_at": "2026-08-02T09:14:22Z",
  "expires_at": null
}
GET https://exm.pl/3xY9zKq

302 Found
Location:      https://shop.example.com/spring/sale?utm_source=newsletter
Cache-Control: private, max-age=300
Content-Length: 0

Four details in there to note.

Creation deliberately does not reuse an existing code. Shorten the same long URL twice and you get two different codes back. Handing back the same code both times looks like free savings, one row instead of two, and it is a trap for three separate reasons.

Two unrelated accounts would now be sharing a single row. That means they share its click count, so neither customer's dashboard is correct. It also means that when the first owner deletes their link, the second owner's billboard stops working, and nobody can explain why. And it leaks information. Anyone can shorten any URL and look at what comes back, and if the code already existed, they have just learned that somebody else shortened that URL before them. Do none of this. If the goal was safe retries, offer an Idempotency-Key header instead. The client sends a unique string with the request, and if you have already processed that exact string, you return the original result rather than creating a second link. That solves retries without any of the sharing.

Generated codes are exactly 7 characters. Custom aliases are 8 to 64. This is one line in the API contract and it deletes an entire class of bug. The generator only ever emits 7 character codes, and every custom alias is at least 8. So a user-chosen alias can never collide with a code the generator produces later, no matter how many links you issue or how the generator changes. You do not need a shared uniqueness check across the two namespaces, because the lengths already keep them apart. Free, permanent, and it survives every future change to the code scheme.

Three hostnames, not one. exm.pl serves only redirects. api.example.com serves the API. example.com serves the marketing site and the dashboard. The reason to split them is that short codes and application routes live in the same namespace if they share a hostname. Every route you have ever shipped, /pricing, /login, /about, permanently burns the code with that name. This goes wrong the other way too. If abc1234 was issued as a link last year and you ship a page at /abc1234 today, that page silently shadows a live link somebody is paying you for. Deciding this at the start costs nothing. Undoing it later means breaking links that are already printed on things.

The status code decides whether you can ever take a link back. A browser that caches a 301 stops asking you, so it keeps sending that user to the old destination even after you disable the link. A 302 does not. That is why this returns 302, and the high-level design works through the rest.


High-level design

1. Generate a code that is unique, unguessable and permanent

Three properties. Unique, meaning no code is ever handed out twice. Unguessable, meaning nobody can walk the space and collect the live ones. Permanent, meaning a code always resolves to the same thing for as long as it exists. The obvious schemes each miss at least one.

Naive

Hash the long URL and take the first seven characters

Run the URL through SHA-256, encode the digest in base62, and keep the first 7 characters. No state to store, no coordination between servers, and the same URL always produces the same code, which looks like free savings.

It misses two of the three, unique and unguessable, and it leaks information on top. Start with uniqueness. SHA-256 produces 256 bits. Seven base62 characters hold log2(62^7), which is about 41.7 bits. So you are throwing away roughly 214 bits of the digest and keeping 42. Two different URLs whose full hashes differ can easily collide on the 42 bits you kept. The question is how soon, and the answer is much sooner than the size of the space suggests.

Seven base62 characters give you 62^7 possible codes, which is 3.52 trillion. Against 100 million links a day that sounds like centuries of headroom. It isn't, because collisions do not wait until the space is full.

This is the birthday paradox. In a room of 23 people there is already a better than even chance two of them share a birthday, even though there are 365 days to choose from. The reason is that you are not comparing each person against a fixed target. You are comparing every person against every other person, and the number of pairs grows far faster than the number of people. Twenty-three people make 253 pairs.

The rule that falls out of it is that collisions start showing up at roughly the square root of the space. The square root of 365 is about 19, and the real answer for birthdays is 23, so the rule is a good guide. The square root of 3.52 trillion is about 1.9 million.

So after roughly 1.9 million links, two different URLs producing the same seven characters stops being unlikely and starts being something you see regularly. You create 100 million links a day. You reach 1.9 million inside the first half hour.

So collisions are not a rare accident here, they are routine. Every write now needs a read first, to check whether the code is taken, plus a retry loop that appends a salt and hashes again when it is. That read-then-write coordination is the exact thing you adopted hashing to avoid.

The second failure: always producing the same code for the same URL leaks information, for the reasons in the API section above. The third: these codes are not unguessable, they are unguessable only if you cannot guess the URL, which is the wrong threat model entirely. An attacker does not guess URLs and hash them. They walk the code space directly, trying aaaaaaa, aaaaaab, and so on, and a hash prefix is just as walkable as anything else.

Underneath all three is one structural problem. Collisions become a correctness question you have to keep answering, forever, on every single write, rather than something you eliminate once by choosing a scheme that cannot produce them.

Senior

Generate random codes and let the database enforce uniqueness

Pick 7 random base62 characters, then insert the row with a uniqueness constraint on the code column so the database itself rejects a duplicate. If the insert comes back as a constraint violation, generate a fresh random code and try again. Unguessable by construction, since the code carries no information about the URL at all. And the database is the one deciding who owns a code, so there is no race between two servers to reason about.

This is the correct standard answer, and you should be able to defend it with arithmetic rather than a shrug:

Back of envelopeDo random collisions actually matter?
Step 1: how big is the space, and how much of it do we fill?
  code space            62^7                  =         3.52T
  links after 10 years  100M/day x 365 x 10   = 365 billion
  occupancy             365B / 3.52T          = 10.4%

Step 2: what are the odds a fresh random code is already taken?
  it is just the occupancy: 1 in 10 of the space is
  occupied, so a random pick lands on a taken code
                                              = 10.4%
  which means about 1 insert in 10 has to retry once

Step 3: how many retries per second is that, in year 10?
  100M creations/day / 86,400                 = 1,157 writes/s
  1,157 x 0.104                               = ~116 retries/s

Step 4: is 116 retries/s a problem?
  no. and note where the cost lands: on the write path,
  in a system with 100 times more reads than writes.
  the expensive path is the cheap one here.

So collisions do not matter, and anyone who spends 5 minutes designing collision handling has misread the problem. The arithmetic settles it in 90 seconds.

What you are actually paying for is subtler, and it is not the retries. It is that every single write has to read the authoritative database, find out whether that code is free, and only then write. In a system this read heavy that is affordable, which is why this answer is fine and will pass most interviews. But notice what you have just coupled together. Creating a link now depends on the latency and the uptime of the database, on the one path you already said could afford to be slow. That coupling is what the next tier removes.

Staff

A leased counter through a keyed permutation

Question the premise. Both answers above treat uniqueness as something you discover, by asking the database whether a code is free. It can instead be something the scheme guarantees, so the question never gets asked.

Start with a counter that only goes up: 1, 2, 3, and so on. Counters cannot collide, that is the whole point of them, so there is no read before the write and no retry path at all. Asking a central sequence for a number on every creation would be its own coordination problem. So each writer leases a block of 10,000 values in one round trip, say 40,000 through 49,999, then hands them out from its own memory. The central sequence now hears from a writer once per 10,000 links instead of once per link.

The obvious problem is that sequential codes are exactly the disaster the Gone in Six Characters paper describes: 0000001, 0000002, and anyone can walk the whole space. So do not publish the counter. Publish a keyed permutation of it.

A permutation here means a shuffle of the entire code space. Picture all 3.52 trillion possible 7 character codes laid out in order, then shuffled into a new order. That new order looks completely random, and it is fixed and repeatable, because a secret key determines it. Counter value 40,000 lands wherever that shuffle puts it, which might be 3xY9zKq. Counter value 40,001 lands somewhere else entirely, nowhere near it. Without the key, the sequence of codes coming out looks like random noise. With the key, you can run the shuffle backwards and recover exactly which counter value produced any given code.

A counter removes collisions, a keyed permutation makes the codes unguessable, and because the permutation can be run backwards you also get a way to reject codes that were never issued, without touching storage.

You need a function that takes a number in [0, 62^7) and scrambles it into a different number in that same range, reversibly, under a secret key. Ordinary AES cannot do it, because AES takes 16 bytes in and gives 16 bytes out, and you need the output to still fit in 7 characters. The tool for this is format-preserving encryption, and FF1 is the standard algorithm. It is reversible by design.

Walking one code through it:

  • Take the next counter value from your leased block. Say 40,000.
  • Encrypt it with FF1 under your secret key. Out comes another number in the same range: 224,957,319,992.
  • Base62 encode that to exactly 7 characters: 3xY9zKq. That is the code you hand out.
  • To go backwards, decode 3xY9zKq to 224,957,319,992, decrypt with the same key, and you are back at 40,000.

FF1 works on power-of-two ranges, and 62^7 is not one. It sits at 3.52 trillion, and the nearest power of two above it is 2^42, which is 4.40 trillion. If an encryption lands in that gap it is out of range, so FF1 encrypts the result again and repeats until it lands inside. That repetition cannot introduce a collision. The encryption shuffles the whole 4.40 trillion range, so two different counter values never land on the same place, and walking past the gap only picks the next valid value along that same shuffle. In practice that is the first or second try, so the cost is a rounding error.

What you get out of it:

  • Collision free by construction. A permutation is a bijection, meaning the mapping goes both ways with nothing left over: every counter value maps to exactly one code, and every code maps back to exactly one counter value. Two different counter values cannot produce the same code, ever. Not rarely, not with low probability. Never. So there is no uniqueness check to run and no retry path to write.
  • Indistinguishable from random to anyone without the key, so walking the code space is as hopeless as it is against truly random codes.
  • No coordination on the write path beyond one lease call per 10,000 links.
  • Reversible. Hand it a code, get back the counter value that produced it. There is no obvious use for that yet, and it removes most of your database read load later, in the deep dive on rejecting codes that were never issued.

What you are accepting, and name all three:

The key is a permanent secret and you can never rotate it for codes already handed out. Changing the key changes the shuffle, so every existing code would decode to the wrong counter value. Rotating in practice means keeping every old key forever and choosing which one to use based on which counter range a code falls into. So treat the key like a root credential: keep it in a KMS or HSM, and keep the permutation itself inside one small isolated service that nothing else can call. If that key leaks, every link you have ever issued becomes scannable in order.

Counter blocks leak a little ordering information. Two links created by the same writer inside the same block sit next to each other in counter space. Someone who can reverse the permutation can therefore tell they were created around the same time. Since reversing it needs the key, that leak is only visible to you.

A crashed writer strands its unspent block. Up to 10,000 codes are burned and never issued. That is 0.0000003 percent of the space per crash, so you would have to crash millions of times before it registered. Not worth engineering around.

collision freeunguessablewrite path costcoordination
Hash prefixno, needs retry loopweakread + conditional writenone
Random + constraintby checkyesread + conditional writenone
Counter, raw base62by constructionno, fully scannablenone1 lease per 10k
Counter + keyed permutationby constructionyesnone1 lease per 10k

2. Where does the mapping live?

Here is everything this system does with the mapping. To serve a redirect, take a short code and return the long URL. That is the only read. To create a link, write one row, once. That is the only write.

Which means there is nothing to join to, since a code maps to one URL and no second table is involved. Nothing is ever read in order, since nobody asks for "all codes between X and Y". And nothing is ever updated, since the row is never touched after it is written. That is enough to choose the data store.

Use a hash-partitioned key-value or wide-column data store. DynamoDB, Cassandra, ScyllaDB and Bigtable all work. Hash-partitioned means the data store runs your key through a hash function, and the number that comes out picks which machine stores that row. Ask for the same key later, you get the same number, so the request goes straight to the right machine. That is all this workload ever needs.

Do partition on the code itself. The code came out of a keyed permutation, so it is already spread evenly across the whole space, which means no part of the space attracts more traffic than any other. Your machines end up with roughly equal load without you arranging it, and you do not need to hash the key a second time on top.

Two details to note:

Uniqueness for custom aliases needs a conditional write, and it is not free everywhere. A conditional write means "insert this row, but only if it does not already exist," decided by the database itself so two simultaneous requests cannot both succeed. DynamoDB does this with an attribute_not_exists condition on a put, at the cost of an ordinary write. Cassandra calls it a lightweight transaction, and it is not lightweight. It runs the Paxos consensus protocol, meaning several rounds of the replicas agreeing with each other before anyone commits. That costs roughly four times a normal write. Aliases are a small fraction of creations so the average cost is fine. What surprises people in production is the shape of the load, because a marketing campaign launches and suddenly a burst of custom links all want Paxos at once.

A mapping that never changes is what makes cross-region replication easy. In a normal multi-region database, two regions can write to the same row at nearly the same time, and something has to decide which write wins. None of that applies here. A row is written once and never modified, so two regions can never hold different versions of the same code, there is no conflict to resolve, and replication between regions is much simpler. You get that from the immutability decision made earlier.

storewhat goes in itthe requirement that forces it
hash-partitioned key-valuethe code to URL mappingsingle-key point reads, no joins, no ordered scans, 365 billion rows, and rows that never change, so cross-region copies need no conflict resolution
one relational instanceowner accounts, billing, custom alias ownershiptiny, relational, and it is the only place a transaction is genuinely wanted
append-only time-partitionedclick eventsa write rate above the redirect rate, never read by key, and dropped wholesale after the retention window
in-memory and edge cachesthe hot mappingthe 20 ms budget, and the data is rebuildable, so no durability

This is one of the few designs where a relational database is the wrong default rather than the safe one.

A relational engine sells you three things: joins, ordered indexes, and transactions across multiple rows. This workload calls none of them. There is nothing to join to, since a code maps to one URL and that is the whole schema. Nothing is ever read in order, so a B-tree index that keeps keys sorted is doing work you never use. And the only place a transaction is genuinely wanted is the conditional insert for a custom alias, which is a single-row condition, not a multi-row transaction. So you would be carrying a feature set you never call, and paying for it in the place that hurts most here, which is write latency across regions. The Postgres you would reach for here has one primary that accepts every write, so a write from any other region pays the round trip to reach it. A key-value store can accept the write in the local region and replicate afterwards, and that is only safe because our rows can never conflict.

What makes the alternative possible is that a mapping is immutable after insert. That is what makes background replication to every region safe with none of the conflict resolution described above, and conflict resolution is usually the thing that makes multi-region key-value stores painful to run.

The tables

-- The hot one. Hash-partitioned on the code itself.
-- Immutable. Written once, cached forever, never updated.
links (
  code         text primary key,      -- the partition key, already uniform
  long_url     text not null,
  owner_id     bigint,
  created_at   timestamptz
)

-- Link state from Core entities, deliberately its own table.
-- Different write pattern, different cache lifetime.
link_state (
  code         text primary key,
  disabled     boolean not null default false,
  expires_at   timestamptz,           -- null for permanent
  takedown_reason smallint
)

-- Small, relational, and the only place a transaction is wanted.
owners (
  owner_id     bigint primary key,
  email        text unique,
  plan         smallint
)

-- Append-only. Partitioned by day so retention is a partition drop.
-- No primary key on purpose: nothing ever fetches one event by id.
-- event_id is carried for the stream processor's dedup window.
clicks (
  day          date,                  -- partition key
  event_id     uuid,                  -- assigned at the edge
  code         text,
  ts           timestamptz,
  country      text,
  referrer     text,
  user_agent_class smallint
)

-- Rollups, read by the dashboard. Never computed on demand.
-- One row per link per day, keyed on code first so a link's whole
-- history sits together, then on day so the dashboard reads a date
-- range in order with no sorting.
click_counts (
  code         text,
  day          date,
  count        bigint,
  primary key (code, day)
)

Three things to note.

The code is the partition key and needs no hashing on top. It came out of a keyed permutation, so it is already spread uniformly across the space, and hashing an already-uniform value again adds nothing.

expires_at lives in link_state, not in links, and it is a plain column rather than a database-level TTL, for two reasons.

Keeping it out of links is what preserves the immutability the whole design rests on. If expiry were a column on links, then links would be a table whose rows change, and you could no longer cache it forever. Splitting it means links keeps its infinite cache lifetime while link_state carries a short one, seconds rather than never.

The second reason is about what a database-level TTL actually does: it deletes the row. Once the row is gone, an expired link is indistinguishable from a code that was never issued at all, because in both cases the lookup returns nothing. Those deserve different answers. A code that expired should return 410 Gone. A code that never existed should return 404 Not Found. Keeping expiry as a column preserves the difference.

Clicks are partitioned by day, so deleting old data is a partition drop rather than a row-by-row delete. 10 billion click events a day is a higher write rate than the redirects themselves. Deleting those rows individually after 90 days would cost more work than writing them did. Every delete is its own write, its own index update, and eventually its own compaction. Dropping a whole day's partition is a metadata operation that costs almost nothing regardless of how many rows are inside it.

The queries that carry the product

-- 1. The redirect, and the only query that matters. 10B/day is
--    350,000/s at peak, and the edge answers 95% of it, so
--    your origin servers see only ~17,500/s. One partition,
--    one key, no index beyond the primary.
SELECT long_url FROM links WHERE code = $1;          -- cached forever
SELECT disabled, expires_at FROM link_state WHERE code = $1;  -- short TTL

-- 2. Create. Two writes, and only the first one is conditional.
--    A generated code came out of a permutation, so it cannot
--    collide, and its insert is unconditional. A custom alias
--    is user-chosen, so it needs IF NOT EXISTS to stop two
--    simultaneous requests both claiming it.
INSERT INTO links (code, long_url, owner_id, created_at)
VALUES ($1, $2, $3, now())
IF NOT EXISTS;                    -- only for custom aliases

INSERT INTO link_state (code, disabled, expires_at)
VALUES ($1, false, $4);           -- expiry lives here, not on links

-- 3. The dashboard. Reads the rollup table, never scans clicks.
SELECT day, count FROM click_counts
WHERE code = $1 AND day >= $2 ORDER BY day;

-- 4. The awkward one. "All links owned by me" asks about a
--    column that is not the partition key.
SELECT code, long_url, created_at FROM links WHERE owner_id = $1;

Query 1 is the entire product, and it is a single-partition point read, which is why choosing the data store was easy.

Query 4 is the one to raise before the interviewer does. It filters on owner_id, which is not the partition key, and the partition key is the only thing a hash-partitioned store knows how to route by. So the data store has no idea which machine holds your links. It has to ask every partition and combine the answers, which is a scatter-gather, and it gets more expensive as you add machines rather than less.

Three options, each with a cost:

  • A global secondary index on owner_id. The data store keeps a second copy of the data organised by owner. It maintains that copy in the background, so it is eventually consistent: a link you just created may not appear in your list for a second or two.
  • A second table keyed by (owner_id, created_at), written by your own creation path at the same time as the main row. You now own keeping two tables in step, and a crash between the two writes leaves them disagreeing.
  • Accept the slow path. A dashboard listing your own links runs maybe once per session, by one user, who is looking at a loading spinner they expect. It is not on the redirect path and does not need to be fast.

Pick one, and say out loud that this is the only query in the design that does not fit the partition key. Noticing that before you are asked is the difference between having built one of these and having read about one.

3. How do you serve a redirect in 20 ms worldwide?

The mapping is a hash table lookup. Everything hard about serving it is geography and volume.

Naive

Application servers behind a load balancer, reading from the database

A fleet of stateless servers, an index on the code column, done. It works, and at low volume it is the right answer.

What breaks is geography, not throughput. A visitor in Jakarta hitting an origin server in Virginia pays about 200 ms just for the network round trip, before your server has executed a single instruction. You promised the whole thing in 20 ms. No amount of caching inside one region touches that number, because the delay is the distance itself. Light takes about 80 ms to cross that path in fibre, and the request has to go there and come back, plus TCP and TLS setup on top. The database was never the problem.

Senior

A regional cache in front of the data store, in several regions

Put Redis or Memcached in front of the key-value store from the last section, DynamoDB or Cassandra. Deploy the whole stack to three or four regions, and use latency-based DNS so each visitor resolves to whichever region is closest to them. DynamoDB is the lower-operations choice, since Global Tables handle the cross-region copying for you; Cassandra is the one to pick if you are already running it or you need to stay off a single cloud. Because entries never change, this cache is correct with no effort at all. Set an infinite expiry and write no invalidation logic. There is no such thing as a stale read here, because there is only ever one version of the value.

Every region holds a full copy, not a slice, so no read ever crosses a region boundary. A visitor hits the nearest region, its cache answers, and on a miss its own replica of the key-value store answers. A creation is accepted by whichever region receives it and propagated to the others in the background, which is only safe because rows are immutable and so two regions can never disagree.

Two costs come with that. Three regions means three copies of 150 TB, so you are paying to store 450 TB instead of 150, plus cross-region transfer on every link created. And there is a window, usually under a second, where a link created in Frankfurt is not yet readable in Oregon. Someone who shares a link the instant they make it can outrun your own replication. That is the failure the creation-to-first-redirect metric later exists to catch.

Then do the sizing. It rests on one assumption about how redirects spread over a link's lifetime, so state the assumption before you lean on it:

Back of envelopeThe dataset is enormous and the working set is not
Step 1: how much data is there in total?
  365B links x ~400 B per row               = 150 TB

Step 2: how much of it is actually being read?
  assume ~90% of redirects hit links created in the
  last 7 days (validate this split with the interviewer,
  it is doing a lot of work here)
  links created in 7 days   100M x 7        = 700M
  of those, only ~20% are ever visited      = 140M

Step 3: how much memory does that hot set need?
  one cache entry: 7 B code + ~100 B URL
                   + overhead              = ~200 B
  pessimistic, cache all 7 days
                   700M x 200 B            = 140 GB
  realistic, cache only the visited ones
                   140M x 200 B            = 28 GB

Step 4: so what?
  90% of 10 billion daily redirects are answered from
  something that fits in one machine's RAM, while the
  other 150 TB sits on disk and is almost never touched.
Short links decay hard. A link is hot for the hours after it is posted and effectively dead forever afterwards, which is close to the ideal shape for a cache.

That gap, 150 TB at rest against tens of gigabytes actually in use, is the entire reason this system is cheap to run. It works because short links decay hard: a link is hot for the hours right after it is posted, then effectively dead forever. If you only get to say one thing about storage in this interview, say that one.

Staff

Serve it at the edge and treat the origin as the exception

Look at what a redirect response actually is: a few hundred bytes of headers and no body at all. The data behind it never changes. The whole hot set is tens of gigabytes. There is no good reason for a request like that to travel to your origin server.

So run the lookup in edge compute instead, meaning code that executes at the CDN's own points of presence (PoPs), the few hundred locations it already operates near users. Visitors reach the nearest one by anycast. Every PoP announces the same IP address, and the internet's own routing delivers each request to whichever one is closest, so there is no DNS trick or client logic involved. Back it with an edge key-value store that holds a copy of the hot set at each location. Cloudflare Workers with Workers KV, or Lambda@Edge with DynamoDB Global Tables, are the two off-the-shelf versions of this. Now the visitor's TLS handshake finishes 20 ms away instead of 200 ms away, and the lookup happens inside the same process that terminated the connection. Your origin sees roughly 5 percent of all requests, which are the misses.

What you are accepting, and say this before the interviewer asks: you have traded one copy-consistency problem for a much larger one. You now have around 300 independent caches spread across the planet, none of which you directly control, all holding copies of your data. The deep dive on takedowns is entirely about the 90 seconds it takes to reach all of them with the news that a link is now malicious. Immutability is what makes fanning data out that widely safe in the first place. The handful of cases that break immutability are what make it hard.

Recording clicks gets harder too. Your own servers used to write those events, and there were four of them. Now the edge answers 95 percent of redirects, so 300 locations you do not run have to send them instead. The analytics deep dive covers how.

Say plainly that this is a build-later decision. Ship regional caching first, measure your p99 latency broken down by geography, and move to the edge when that map tells you where the pain is. Standing up edge compute before you have that data is buying an operational burden on a guess.

95 percent of traffic never leaves the edge, and the layers below it exist for the 5 percent that does. Two more checks get added in front of the edge lookup later, in the deep dives.

Now the status code.

The usual framing is "301 is cached so you lose analytics, therefore use 302." That is half right, and it credits the wrong mechanism. The status code only sets the browser's default behaviour when you say nothing else. The Cache-Control header is what actually decides how long a response is cached, and it overrides the default. So you can send a 301 that is not cached, or a 302 that is cached for an hour. Choose both deliberately rather than assuming the status code settles it.

statusmeaningdefault cachinguse it for
301moved permanentlycached hard, often for the life of the browser profilealmost never here
302foundnot cached by defaultthe normal redirect
307temporary, method preservednot cachedequivalent for GET, safer for anything else
404no such codeshortcodes that were never issued
410gonecacheablecodes that existed and were deleted or expired
451unavailable for legal reasonsshortcourt-ordered or regulatory takedowns

Bitly's own support documentation describes their redirects as permanent, on the grounds that they never reuse or modify a short link. Given that promise, it is a coherent position. The argument against it is not really about analytics. It is about being able to take a link back.

A cached 301 cannot be recalled. When a browser caches a permanent redirect, it stops asking you. The next time that user clicks the link, the browser goes straight to the destination and your server never sees the request, sometimes for the entire life of the browser profile. Now suppose the destination gets compromised six months after the link was created. You disable the link at your end, and it changes nothing for those users, because they are not talking to you anymore. Every one of them keeps landing on the malware and there is no mechanism by which you can stop it. That is a security property, and it is worth more than the bandwidth a 301 saves you.

So: return 302, and set Cache-Control: private, max-age=300 explicitly rather than relying on the default. 5 minutes of caching still absorbs the repeat traffic from one person clicking the same link a few times in a row. It keeps your click counts close to right. Most importantly it caps at 5 minutes the window during which you cannot pull a link back. If you want more caching or less, tune that number. Do not reach for a different status code to get it.

Two more behaviours to note, because they come up and surprise people:

  • Fragments pass through by themselves. The #section part of a URL never leaves the browser, it is not sent to the server at all. When the browser receives a redirect, it takes whatever fragment it was holding and reattaches it to the new Location. So exm.pl/3xY9zKq#pricing lands on the destination with #pricing still attached, and you wrote no code for that. It is a happy accident when the destination has no fragment of its own, and a collision when it does.
  • Query strings do not. A query string like ?utm_source=x is sent to your server. So if someone appends one to a short link, you have to decide whether to merge it into the destination URL or drop it. Most shorteners drop it. Either is defensible. Pick one, write it down, and never change it, because marketing teams will build reporting on whichever answer they observe.

4. Record the click without slowing the redirect

The redirect handler must never wait for analytics. Append the event to a buffer in local memory, return the redirect to the visitor immediately, and let a background thread flush that buffer to the log pipeline every so often. If the pipeline is unreachable, throw the events away and keep redirecting.

That order matters, and it is not about speed. Requirement 5 already said the mapping may not be lost and click counts may be approximate, so dropping a click event is allowed and failing a redirect is not. If a redirect fails because a Kafka broker is unhealthy, you have broken the one path that has to work, for the sake of a number nobody clicking the link cares about.

One thing to raise here even though the pipeline itself is a deep dive: a large share of what looks like clicks is not people. Post a link in Slack, Twitter, iMessage or WhatsApp and that platform immediately fetches it to build the preview card, before any human has seen it. Email a link into a corporate inbox and the security gateway fetches it to scan where it goes. Archive crawlers sweep continuously. On some sets of links the machines outnumber the humans. If you count all of that as clicks, the number your customer is paying you to show them is simply wrong. Worse, it is wrong in the direction that flatters you, so nobody complains. Filtering belongs in the design from the start, not as a correction after a customer notices.


Deep dives

The first three come up in almost every version of this interview. Numbers four and seven are where you can separate yourself, because almost nobody prepares them.

1. How many characters should a code be?

Do this arithmetic out loud. It takes 90 seconds and it settles two arguments at once, how long the space lasts and whether it can be scanned.

Back of envelopeSizing the code space
Alphabet: base62 (A-Z, a-z, 0-9)

Step 1: how many codes does each length give you?
  6 chars   62^6  =           56.8B
  7 chars   62^7  =             3.52T
  8 chars   62^8  =              218T

Step 2: at 100M new links a day, when does each run out?
  6 chars   56.8B / 100M            =    568 days
  7 chars   3.52T / 100M            =     96 years
  8 chars    218T / 100M            =  5,973 years

Step 3: how full is the 7 char space after 10 years?
  365B / 3.52T                      =   10.4%
  so 9 out of 10 random guesses still hit nothing

Step 4: what does one more character cost, and what does
one fewer cost?
  one more:  1 byte per row, 1 byte per response header,
             one more character a human has to copy
  one fewer: rewriting every link you ever issued, which
             is impossible, because they are permanent

Seven is the answer. Here is why six and eight are not.

Six fails for two reasons. It runs dry in under 2 years, and 56.8 billion is small enough to walk exhaustively, which is exactly the attack the Gone in Six Characters paper carried out, on a 6 character space. Eight buys you another factor of 62 in exchange for one byte, so if you have any reason to think you will be 10 times bigger than these numbers, take eight. The asymmetry is the whole argument: one extra character costs you almost nothing, and one too few costs you a migration you cannot perform.

2. Where do the 20 ms go, and where does the CPU go?

Back of envelopeWhat a redirect actually costs
Step 1: latency at p99, by where the request gets answered
  edge KV hit, same PoP              1 to 5 ms
  regional cache hit                 3 to 8 ms
  origin KV store point read        10 to 25 ms
  cross-region fallback             60 to 150 ms

Both CPU figures below are for whichever tier terminates the
connection and answers from its own memory: your servers in
the regional design, the CDN's PoPs once you move to the edge.

Step 2: CPU for the lookup itself
  peak traffic: 10B/day is 116,000/s, x3 for peak
                            = 350,000 redirects/s
  one hash lookup in the serving process's own memory
  costs ~100 ns of CPU
  350,000 x 100 ns          = 0.035 s of CPU per second
  one core delivers 1 s of CPU per second, so
                            = 0.035 cores

Step 3: CPU for terminating TLS, same traffic
  ~40% of requests arrive on a brand new connection and
  need a full handshake, which is public-key crypto
  350,000 x 0.40            = 140,000 handshakes/s
  a core does ~10,000 of those a second, so one
  handshake costs 1/10,000 s = ~100 us of CPU
  140,000 x 100 us          = 14 s of CPU per second
                            = 14 cores

Step 4: why the gap is so large
  a handshake costs 100 us, a lookup costs 100 ns,
  so a handshake is 1,000x more expensive
  but only 40% of requests need one
  1,000 x 0.40              = 400x

  Note the ratio has no traffic figure in it. Whatever
  your volume, setting up the connection costs roughly
  400 times more CPU than the thing the service exists
  to do.

The handshake estimate in step 3 is rough, and it does not need to be better. It could be wrong by a factor of three in either direction and the conclusion is unchanged: a URL shortener is a TLS termination service that happens to also do a hash lookup. Everything that follows from that is worth more than any amount of tuning on the lookup:

  • End TLS at the edge, physically close to the user. A handshake costs round trips, and round trips cost distance. Doing the crypto near the visitor is the single biggest win available.
  • Turn on TLS 1.3 and session resumption aggressively. Resumption lets a returning visitor skip the expensive public-key part by reusing a secret from last time. TLS 1.3 also offers 0-RTT, where the client sends its request along with the first packet and pays no round trip at all. 0-RTT is normally risky, because an attacker who captures that first packet can replay it and cause whatever it caused the first time. Here the request is a GET that changes nothing on your side except a click event you were already willing to lose. This is one of the few places 0-RTT is straightforwardly safe. There is a good chance your interviewer has not thought about it, so saying it, and saying why, is an easy way to show depth.
  • Get the short domain onto the HSTS preload list. Without it, a visitor who types the bare domain sends a plaintext HTTP request first, gets redirected to HTTPS, and only then gets your real redirect. That is a wasted round trip on every such visit. Preloading ships your domain inside the browser itself, so it goes straight to HTTPS on the first try.
  • Keep the response minimal. No body, no cookies, no headers you do not need. At 10 billion requests a day, an extra 200 bytes of headers is 2 TB a day of egress you are paying for and getting nothing back from.

3. How many servers, and what actually needs scaling here?

Serving 10 billion redirects a day sounds like it should force a large fleet. Work it out and it does the opposite, and the answer also tells you which part of this system is the one actually worth staffing.

Back of envelopeSizing both paths, and the gap between them
Step 1: how much redirect traffic, and where does it land?
  10B/day / 86,400                        = 116,000/s average
  peak at 3x average                      = 350,000/s

  95% answered at the edge                = 332,500/s
  spread over ~300 PoPs                   =   1,100/s each
  origin sees the remaining 5%            =  17,500/s
  at ~5,000/s per instance                =       4 instances

Step 2: how much creation traffic?
  100M/day / 86,400                       =   1,160/s average
  peak at 3x                              =   3,500/s
  at ~5,000/s per instance                =       1 instance
                                       call it 2 for failover
  and the sequence gets called once per 10,000 links:
  3,500 / 10,000                          =    0.35 lease calls/s

Step 3: how much click-event traffic? this is the tier
that actually grows. Discussed in detail in deep dive 8.
  350,000/s x ~300 B per event            =  104 MB/s
  (~300 B is the Click event from Core entities: code,
   timestamp, country, user agent, referrer)
  one Kafka partition sustains ~10 MB/s
  104 / 10                                =      11 partitions
                                              minimum
  round up for consumer parallelism       = 48 to 64 partitions

Four origin instances and two writers, for a system serving 10 billion redirects a day. That is the value of the edge tier stated as a number. The instinct in the room is to draw a large fleet with an autoscaling group attached, and the arithmetic says you are running a rounding error of servers behind a very good cache.

The write path barely exists at all. Leasing counter blocks of 10,000 turns 3,500 creations a second into roughly one call to the central sequence every 3 seconds. The component everyone spends 20 minutes of the interview designing needs no scaling story whatsoever.

A viral link does not create a hot key, which is the fourth thing you get from the mapping never changing. One link taking 100,000 requests a second would be a serious problem in most designs. It is one key, which hashes to one partition, which lives on one node, and no amount of extra machines divides a single key. Here it is a non-event. Every PoP already holds its own copy of that value, the copy can never be out of date, and each request is answered wherever it lands. The load spreads itself across 300 locations without anyone arranging it.

Contrast a rate limiter. There the value behind a key is a counter that changes on every single request, so it cannot be copied at all, and hot keys are among the hardest problems in that design. Same traffic pattern, opposite difficulty. The thing that separates them is not the volume. It is whether the value being read can change.

So the tier that actually grows is the one nobody looks at, which is why it has its own step in the worksheet above. At peak that is 104 MB/s of click events, and that is a Kafka sizing problem rather than a server-count problem.

Pick the partition count for the number of consumers you will need in 2 years, not for today's byte rate. Eleven partitions carry the bytes fine. They also cap you at eleven stream processors reading in parallel, because one partition can only be read by one consumer in a group at a time. Going wider later means either re-partitioning, which shuffles the order of events for a given code, or standing up a second topic and migrating onto it. The byte rate is the easy limit to satisfy. The consumer ceiling is the one that hurts, and it is invisible until you hit it.

Two smaller points to note.

Autoscaling the origin is close to pointless here, and slightly dangerous. Four servers is below the size at which scaling rules behave sensibly, since one instance leaving is 25 percent of your capacity. And the failure you would want autoscaling for is a wave of cache misses after an edge deploy. That can double origin traffic in seconds, far faster than a new machine can boot and warm up. Run the tier with spare headroom instead. At four servers the headroom costs less than the meetings about whether to have it.

The edge is where capacity planning really happens, except that you do not do it. The CDN operates those 300 PoPs and sizes them. Your only job is keeping the working set small enough to stay resident in their memory, which the sizing section already showed is tens of gigabytes.

4. How do you reject codes that were never issued?

Here is the payoff for choosing a reversible code scheme, and it is the highest-value idea in this design.

Shorteners are scanned constantly. Automated crawlers walk the code space looking for anything live, and a large share of your incoming traffic is requests for codes that were never handed out to anyone. Handled the obvious way, each of those costs you a cache lookup that misses, then a database read that also finds nothing, then a 404. The cache cannot help, because there is nothing to cache. So a large fraction of your storage load can be lookups for things that do not exist, paid for at full price, on behalf of an attacker.

The usual fix is to remember the misses, meaning cache the fact that a code is absent. That works, and it has an ugly failure mode we will come to. With a reversible code scheme you can do something strictly better: answer the question with arithmetic and no I/O at all.

Because the permutation is invertible, any code decodes to the counter value that would have produced it. If that value was never issued, the code cannot exist, and you know it without touching storage.

Here is the mechanism. Codes are issued in counter order: counter value 1 was the first link ever created, and the counter has climbed steadily since. So at any moment there is a high water mark, the highest counter value ever handed out. Everything below it might be a real link. Everything above it definitely is not, because that value has not been reached yet.

The permutation is reversible, so given any incoming code you can run it backwards and recover the counter value n that would have produced it. Compare n against the high water mark. If n is above it, that code has never been minted, and could not have been, so return 404 immediately. No cache lookup, no database read, nothing but about 1.25 AES operations and an integer comparison, which is a few microseconds.

Back of envelopeHow much traffic this eliminates
A random probe only survives the filter if it happens to
decode to a counter value below the high water mark. So
the survival rate is just the fraction of the space issued
so far.

Step 1: how full is the space at each point in time?
  after year 1    36.5B / 3.52T      =  1.0%
  after year 5   182.5B / 3.52T      =  5.2%
  after year 10    365B / 3.52T      = 10.4%

Step 2: so what fraction of scanner traffic gets rejected
without touching storage?
  after year 1                       = 99.0% rejected
  after year 5                       = 94.8% rejected
  after year 10                      = 89.6% rejected

Step 3: what does this cost to run?
  one integer per edge process, refreshed periodically,
  plus ~1.25 AES operations per rejected request.
  no cache to size, nothing to evict, no cold start.

It does get weaker as the space fills, so say so rather than glossing over it. But even in year 10, at 10.4 percent occupancy, it throws away nearly 9 out of every 10 scanner requests for the price of comparing two integers. Compare that with caching misses, where you would need to hold an entry for every distinct bad code a scanner tries, which is unbounded by design.

Two things to get right, and the second only shows up if you actually think it through:

The high water mark must be rounded up, never down. Every PoP holds its own copy, refreshed every few seconds, so at any instant some copies are slightly behind. Now think about what "behind" means here. A stale copy is too low. A link created 10 seconds ago has a counter value above that stale mark. So the edge decodes it, sees a value past its own high water mark, and returns 404 for a link that genuinely exists.

That is the worst bug in the whole design, because of who it hits: the person who just created a link and is testing it. Nobody else notices, and they cannot reproduce it for you. The fix is to never publish the true mark. Publish high_water_mark + a generous reserve, where the reserve is larger than any block a writer could possibly be holding, say 1 million. A stale bound now only makes the filter slightly less effective, letting a few extra scanner requests through, and it can never reject a real link. Be wrong in the direction that costs you a database read, not in the direction that breaks a customer's link.

Custom aliases sit outside this entirely. They are 8 to 64 characters and the generated space is exactly 7, so a simple length check routes them past this filter before it runs. That is the second problem removed by one line in the API contract.

Not every fake code gets caught by the filter. The ones that slip through cost you a real lookup that comes back empty. If you remember that empty result, the next request for the same code is free.

One danger comes with that. Suppose you cache "this code does not exist", and then that exact code gets issued to a real customer 200 milliseconds later. Their brand new link now returns 404 until your cached answer expires, and the only person who notices is the one person testing their own link.

Two rules keep it safe. Cache a miss for a few seconds at most, never minutes. And never cache a miss for a code whose counter value falls inside the reserve, because those are exactly the codes you are about to hand out.

5. Custom aliases, where uniqueness stops being free

Everything so far worked because codes came out of one shuffle that nobody competes over. Nobody asks for counter value 40,000 specifically, so there is never a contest. Custom aliases are the exact opposite. Users pick them, users want the same ones as each other, and two people can want the same one at the same moment.

Within a single region this is an ordinary conditional write. Insert into the alias table with if not exists, and whichever request the database processes second fails the condition and gets a 409 Conflict. Cheap on DynamoDB. Four times the cost of a normal write on Cassandra, because it runs Paxos to get the replicas to agree first. Either way it is irrelevant at these volumes, since aliases are a small fraction of a small write path.

Across regions it is genuinely hard, and it is the only place in this whole design that needs real coordination. Two users, one in Frankfurt and one in Oregon, both claim exm.pl/superbowl in the same second. Each region's local conditional write succeeds, because neither knows about the other yet. Then the background replication tries to reconcile them and has no basis for choosing, because the entire replication design was built on the assumption that rows never conflict. Whichever rule you fall back on, last-write-wins on timestamp or anything else, one customer's link silently starts pointing at a stranger's destination.

Three workable answers, in ascending order of effort:

  • Give the alias namespace one home region. Every alias claim, from anywhere in the world, goes to that one region, which owns the ledger and is the only place the conditional write happens. Simple and correct. The cost is that alias creation now inherits that one region's uptime and its round-trip latency from everywhere else. Since alias creation is a small fraction of a small fraction of your traffic, this is usually the right answer, and you should say so before designing anything cleverer.
  • Split the namespace by hash of the alias. Region A owns every alias whose hash starts in the first third of the space, region B the second, and so on. Coordination is now per-alias rather than global, and no single region is a bottleneck for all claims. More moving parts, and better isolation when one region has a bad day.
  • Claim then confirm. Hand back the alias immediately marked as pending, resolve conflicts in the background, and tell the loser afterwards. Do not do this. Users copy the short link into a campaign email within seconds of seeing it, long before your background job has decided they do not own it.

Three product problems ride along with the technical one, and raising them shows you have thought past the database. Users will squat on exm.pl/google and exm.pl/paypal, so you need a reserved-name list and a trademark dispute process before launch rather than after the first legal letter. An alias is user-written text that appears inside a URL on your domain, so it needs the same profanity and impersonation filtering you would apply to a username. And an alias attached to a deleted link can never be handed to anyone else, for exactly the same reason a generated code cannot, which the next section is about.

6. Expiry, deletion, and the codes you can never take back

This is where the promise breaks, and one rule governs all of it: a code is never reused. Not after expiry, not after deletion, not ever.

The reason is a specific attack rather than a principle you can argue about. Suppose codes were recycled once they expired. I go find a short link printed on the packaging of a physical product, or inside a QR code on a conference banner, or sitting in a 5 year old SMS. I wait for it to expire. Then I register that same code myself, pointing wherever I like. Every future scan of that permanent physical object now sends someone to my destination. The original owner can do nothing about it, and neither can you, because nobody can recall a printed object. Any design that frees codes for reuse has this hole in it.

Say this in the interview. The obvious answer is a background job that deletes expired rows, and it sounds fine right up until you notice it makes the code available for reuse.

So deletion is always soft. Clear the destination URL out of the row, mark the link_state row as disabled, and leave behind a permanent marker that keeps the code claimed forever.

That takes effect on its own, within seconds. link_state is read on the redirect path with a cache lifetime of seconds rather than forever. So a user deleting their own link, or a link reaching its expires_at, goes dark as soon as that entry expires. Nothing has to be pushed anywhere and nobody has to be paged. The purge and denylist machinery below is for the urgent case only, where you need every one of 300 caches to stop within seconds and you need to be able to see which ones have not. The markers are tiny compared with full rows: 7 bytes of code plus a flag, against roughly 400 bytes for a live link. So you can compact the table hard, and the storage growth from dead codes never becomes a number anyone worries about.

That shape happens to satisfy a legal requirement for free. A long URL can carry personal data in its query parameters, so a deletion request under GDPR or similar obliges you to erase the destination. It does not oblige you to release the code, because the code itself is not personal data. Clearing the URL while keeping the marker is exactly the right behaviour, and it is the same mechanism you already built.

Return 410 Gone rather than 404 Not Found for a code that existed and was withdrawn. It is more truthful and it is cacheable, where a 404 usually is not. It also lets a client tell "this was never a link" apart from "this link is over," which are two situations a client might handle differently. The argument against is that a 410 confirms the code was once live, which leaks a little information to a scanner. Take the honest status code anyway. What the scanner learns is worth less than what your legitimate clients gain.

Making a takedown actually take effect

Marking the row is the easy half. The hard half is that the mapping is cached forever, at every PoP, deliberately. So how does "this link is off" reach 300 caches, and how fast? A phishing campaign does most of its damage in the first few minutes, so the answer has to be seconds.

Naive

Set a short TTL everywhere and wait it out

Give every cache entry a 5 minute expiry, and a disabled link goes dark within 5 minutes on its own, with no takedown machinery at all.

Two problems, and the first one matters more. You have just thrown away the property the entire design rests on. With a 5 minute expiry, every link outside the hot set gets evicted before it is asked for again. Your cache hit rate collapses for the long tail, and the origin starts seeing traffic it was never sized for. You built infinite caching into the design on purpose and then removed it to solve an unrelated problem.

The second problem is that 5 minutes is far too slow anyway. A phishing campaign does most of its damage in the first few minutes after the link goes out, which is why abuse teams measure takedown time in seconds, not minutes.

Senior

Explicit purge broadcast on takedown

Keep infinite expiry on the mapping, and when a link is disabled, send a purge message to every cache tier telling it to drop that specific entry. Modern CDNs complete a worldwide purge in a few seconds, and your own Redis fleet is faster than that.

Correct, and name the leftover risk yourself rather than waiting for the interviewer to find it. This is a fan-out to hundreds of independent caches, and it fails in exactly the situation where you need it most. A dropped message. A PoP network-partitioned at that moment. A node that restarts and rejoins holding a warm cache from before the purge. Each one leaves a stale entry quietly serving a malicious link. And because you set infinite expiry, there is no timeout underneath to eventually save you. A purge is a fire-and-forget push, and a safety mechanism should not rest on best effort alone.

Staff

Push a denylist instead of purging a cache

Turn the problem around. Instead of trying to remove one entry from 300 caches, ship the set of disabled codes to every PoP and have each one check that set before it serves anything.

Disabled links are rare, well under 1 percent of all links, so the set is small. Small enough to hold in every PoP's own memory as a Bloom filter, which is a compact structure for answering "is this item in the set?" without storing the items. It works by hashing the item several different ways and setting those bit positions in a bit array; to test membership you hash the same ways and check whether all those bits are set. Because different items can set overlapping bits, it can say "probably present" for something that was never added, which is a false positive. It can never say "absent" for something that was added.

That one-sided error is exactly the direction you need. A hit on the filter means "possibly disabled," so you fall through to an authoritative lookup, which costs you one extra read on a small number of false alarms. A miss means "definitely not disabled," and you can trust it. A disabled link cannot slip through a Bloom filter. Only a live link can be slowed down by one. If the error went the other way, this design would be unusable.

The real reason this beats purging is the failure mode, not the speed. A purge message that never arrives fails silently, and the link keeps working with nothing anywhere indicating a problem. A denylist that fails to update leaves the edge holding an old version of the list, and that is a state you can see and alarm on. Every PoP reports the version number of the list it currently holds. You can look at a dashboard and say "17 PoPs are 4 versions behind." Fire-and-forget purging can never tell you that, because there is nothing to be behind on.

Layer both mechanisms. Purge for speed, denylist for the guarantee, and keep a short expiry on link_state as a third floor underneath both. Then set an actual target and measure it continuously rather than assuming it: 99 percent of PoPs enforcing a takedown within 30 seconds.

7. You have shipped a phishing tool

Say this in the interview, because it is true, almost nobody raises it, and it changes what the rest of the design has to support.

A URL shortener's entire function is to hide a destination behind a meaningless code on a domain the recipient already trusts. That description also fits every credential-stealing campaign ever run. Within days of launching, a real share of your traffic is abuse, and it never stops being a share of your traffic. This is not a feature you bolt on in year two. It is a permanent operating cost of being in this business at all.

What the design has to support:

Scan the destination at creation. Before you return a code, check the URL against Google Safe Browsing, PhishTank, and whatever reputation data you have built yourself. This is fast, it is imperfect, and it catches the lazy majority who are reusing a domain that is already burned.

Re-scan continuously, because scanning at creation is trivially defeated. The standard technique is a time bomb. The attacker registers a link pointing at a completely harmless page, lets it pass every check, lets it build up reputation for a week, and then changes what that page serves. Your scan passed. The page it scanned no longer exists. Nothing about your original check is worth anything now. This is the concrete reason link_state exists as a separate table, and the reason the takedown path has to be fast rather than merely correct.

Check the URL scheme, at creation and again at redirect time. Only http and https should ever end up in a Location header. If a javascript: or data: URL gets stored and later served, your redirect becomes a cross-site scripting vector running on your own trusted domain, which is about the worst place for one. Checking again at read time costs a string comparison and covers you against a bad migration, a corrupted row, or a bug in the creation path you have not found yet.

Serve a warning page instead of a redirect for anything suspicious. The intermediate page shows the full destination URL and makes the visitor click through, which turns an invisible redirect into an informed choice. Every large shortener does this, and it is the right behaviour for the grey zone where you are not confident enough to block outright.

Rate limit creation, and treat creation volume itself as an abuse signal. An account creating 50,000 links an hour pointing at 50,000 different hostnames is not running a marketing campaign. The pattern, many distinct destinations from one account in a short window, is a stronger signal than any individual URL.

Publish the destination. Bitly historically let anyone append a + to a short link to see where it goes instead of following it. A documented preview endpoint costs almost nothing to build and it is the single most useful thing you can offer the people receiving links your service generated, who never asked to trust you.

8. Click analytics, the actual write-heavy system

10 billion click events a day, against 100 million link creations. The analytics pipeline carries 100 times the write volume of the system it is bolted onto, and it is the part customers are actually paying for. Treating it as a footnote is the most common way to under-answer this question.

Two independent readers of one log. One keeps every event and is never queried live, the other keeps almost nothing and answers every dashboard query.
Back of envelopeVolume, and what it forces
Step 1: how much data is the pipeline carrying?
  events                                  10B/day
  raw event size (code, ts, geo, UA,
  referrer)                               ~300 B
  raw volume        10B x 300 B         =    3 TB/day
                    3 TB x 365          = ~1.1 PB/year

Step 2: can the dashboard query that directly?
  a dashboard asks for clicks by day, country, referrer
  and device, for one link over one year.
  scanning raw events for that touches millions of rows.
  reading a pre-aggregated daily rollup touches tens.

Step 3: so the storage splits three ways
  rollups   -> a columnar store, every dashboard query
  raw       -> object storage, for backfill and ad hoc
  retention -> raw kept 90 days, rollups kept forever

The pipeline works like this. The edge fires each click event into a partitioned log such as Kafka and immediately forgets about it. Partition by code, so every event for one link lands on one partition and therefore stays in order relative to the others for that link. The log then has two independent readers. One is an archive sink, in practice a Kafka Connect job writing to S3, which copies every event to object storage untouched. No filtering, no dedup. That is the point: when your bot rules turn out to be wrong, you can reprocess history with better ones.

The other is a stream processor that filters bots, drops duplicates, and adds country and device information. It writes rollups, meaning counts computed ahead of time and stored one row per link per day, to a columnar store like ClickHouse or Druid, which is built for exactly this shape of query.

Keeping them independent matters. A broken stream processor costs you fresh dashboards and no raw data at all, because the sink never depended on it.

A rollup is not computed by waiting for the day to end. The processor keeps one counter per (code, day) in local state, increments it as events arrive, and flushes the changed counters to the columnar store every few seconds as upserts. So (3xY9zKq, 2026-08-22) gets written as 4, then 57, then 1,203 as the day goes on, and the dashboard always reads the latest value. That is what makes a click appear seconds after someone made it.

Look at the stream processor again. It is holding a live counter for every link that got a click today, and those counters sit on its own machine. If that machine dies, they die with it, and every dashboard's total for today restarts at zero.

You do not build the fix yourself, you get it from whichever framework you use to write the processor. Kafka Streams keeps the counters in a small database inside its own process and copies every change to a Kafka topic, so a replacement machine replays that topic and rebuilds them. Flink does the same job by snapshotting its counters to object storage every so often. Either way a restart is not instant, because the counters have to be loaded back before counting can resume.

And midnight is not the end of a day. Events arrive late, so the counter for 22 August stays open past midnight for a grace period. Anything arriving later than that never reaches the streaming rollup at all, which is what the batch correction below is for.

Two things change now that the events come from 300 PoPs instead of four of your own servers.

First, distance. Your Kafka cluster lives in one region, maybe two. If every PoP sends every click event straight to it, a click in Tokyo travels to Virginia on its own, and you pay for that trip 10 billion times a day. Put a small collector service in each region instead. Each PoP sends to its nearest collector, the collector batches events up, and only the batch makes the long trip.

Second, edge code does not stay running. A worker handles a request and may be shut down immediately afterwards, so you cannot hold 10,000 events in memory and flush them every 30 seconds the way a long-running server would. The worker might be gone first. So you send small batches, often, which means more calls to the collector and a few events lost whenever a worker disappears mid-batch. Requirement 5 already said click counts can be approximate, so that loss is affordable.

Four things decide whether the product is any good:

Bot filtering is a correctness requirement, not tidiness. Link preview crawlers from Slack, Twitter, Facebook and WhatsApp fetch a link within seconds of it being posted, before any human sees it. Corporate mail gateways follow every URL in every message they scan. Archive crawlers sweep continuously. Filter on three signals: the user agent string, the published IP ranges that the big platforms document for their crawlers, and behaviour, such as one address fetching thousands of unrelated codes. Then show the filtered count separately on the dashboard, so the customer can see you did it and how much you removed. A dashboard reporting 40,000 clicks when 30,000 of them were scanners is worse than no dashboard, because the customer will make decisions on it.

Drop duplicates using an event ID. Three separate things produce duplicate events: Kafka's at-least-once delivery, which will redeliver on any uncertainty, client retries, and browsers that follow a redirect twice. Generate a UUID for each event at the edge, and have the stream processor keep a moving window of recently seen IDs and drop anything it has already processed. The window only needs to cover the realistic redelivery gap, minutes rather than days.

Unique visitors can only ever be estimated. Counting exact distinct visitors over a petabyte means storing every visitor identifier you have ever seen for every link, which costs far more than the answer is worth. Use HyperLogLog sketches instead. A sketch is a small fixed-size structure, a few kilobytes, that estimates how many distinct items it has seen. The error is a known bound, roughly 2 percent, whether it saw 1,000 items or a billion. Two properties matter on top of the size. The sketch does not grow as the count grows. And two sketches merge into one that is exactly the sketch you would have got by counting both streams together from the start. So you can add up any time range on demand, without recounting anything.

Split fresh from correct. Users want a click to appear on the dashboard within seconds of sharing a link, and they also want the monthly total to be right. Those are different guarantees and one pipeline cannot give both. So serve recent windows from the streaming rollups, which are fast and slightly wrong, and older windows from batch-recomputed totals, which are slower and correct. Let the batch layer quietly overwrite the streaming layer's numbers as late-arriving data lands.

9. How do you know it is working?

There are four questions you must be able to answer during an incident. What fraction of redirects are failing, and where. How far behind is each PoP on the denylist. How much traffic is each layer answering. And is a takedown we issued half an hour ago actually being enforced.

  • Redirect success rate and latency, split by PoP. The split is the important part. One bad PoP out of 300 is completely invisible in a global average, and the people it affects all live in the same place and all think your service is broken.
  • Cache hit rate per layer, as your primary cost and latency signal. A drop here shows up on your CDN bill before it shows up as a latency graph anyone notices.
  • How far each PoP is behind on the denylist, with an alarm on it. This is a safety metric rather than a performance one, and it is the thing the denylist design exists to make measurable in the first place.
  • Time from creation to first successful redirect. This catches the case where someone cannot read what they just wrote, which is otherwise invisible. The only person affected is one user testing their own brand new link, and they will assume they typed it wrong. The high water mark bug from deep dive 4 shows up here and nowhere else.
  • Never label a metric with the code. A metric labelled by code creates one time series per link, and there are hundreds of billions of links. That will take down your metrics backend, and it will do it during an incident when link creation spikes. Codes belong in sampled logs and top-N summaries, not in metric labels.

Final design

Read path across the top, write path and control plane below, analytics on its own track. The two paths share a datastore and nothing else.

The read path, one redirect end to end

  1. Anycast routes the visitor to the nearest PoP, which terminates TLS locally, usually resuming an existing session rather than paying for a full handshake.
  2. The edge runs the permutation backwards on the code. If the counter value that comes out sits above the published high water mark, it returns 404 immediately without touching storage. That is what happens to roughly 9 out of 10 scanner requests.
  3. It checks the code against the denylist Bloom filter held in the same process. A hit falls through to an authoritative state lookup. A miss continues, and a miss is trustworthy.
  4. It reads the mapping from the edge key-value store. That hits about 95 percent of the time.
  5. It reads the link's state, which is cached for a few seconds rather than forever, and returns 410 Gone if the link is disabled or past its expiry. This is what makes an ordinary deletion or an expiry take effect, and it is the floor underneath the purge and the denylist.
  6. If the link is live, it returns 302 with the destination and Cache-Control: private, max-age=300.
  7. On a mapping miss, it falls through to the regional cache and then to the origin key-value store, filling both on the way back. An edge miss never returns 404 on its own authority. Only the origin is allowed to decide that a code does not exist, because only the origin knows.
  8. It fires a click event into the log pipeline and forgets about it immediately. If that fails, the event is dropped and the redirect is unaffected.
  9. Bot filtering, duplicate removal and enrichment all happen in the stream processor. Rollups land within seconds. Raw events land in object storage for later backfill.
  1. The API validates the URL and its scheme, then scans the destination against known-bad reputation data.
  2. It takes the next value from the writer's leased counter block. There is no call to the central sequence unless that block is exhausted, which happens once per 10,000 links.
  3. It encrypts that value under the permutation key and base62 encodes the result into exactly 7 characters.
  4. It writes the links row and its link_state row, then returns. No uniqueness check and no retry loop, because a permutation cannot produce the same code twice.

The numbers that justify the shape

150 TB at rest against a working set under 150 GB. 10 billion redirects a day, of which 95 percent never leave the edge. 90 percent of the invalid requests rejected by arithmetic before any storage is touched. And a code space that lasts 96 years.


What is expected at each level

Mid-level

Breadth over depth, and it is fine to be led through the deep dives.

You should land a working design. A code generation scheme with a real reason behind it. A key-value store rather than a relational one, and why. A cache in front of it, and a redirect that returns the right status code. You should know that the system is read heavy and that caching therefore matters.

You are not expected to raise abuse, analytics volume, or takedown speed on your own. You should be able to reason about them once the interviewer puts them in front of you. Getting the collision arithmetic roughly right when prompted is a good signal at this level.

Senior

You drive the structure, and three things should come up without prompting.

A code scheme you can defend with numbers. Either random plus a uniqueness constraint or a counter plus a permutation is fine. What is not fine is spending 5 minutes on collision handling without ever working out the collision probability. The arithmetic shows it is a non-problem, and the fact that you did not run it shows.

Real sizing. Total data against working set is the expected observation, and reaching "150 TB at rest but the hot set fits in RAM" is the moment this design becomes cheap. If you size the database and never size the cache, you have sized the wrong thing.

Unguessability stated as a requirement, not discovered when the interviewer asks about security. Sequential public codes should feel disqualifying to you immediately.

When prompted you should get to edge serving. Also to why the status code choice is about being able to take a link back rather than about analytics, and to the fact that click events must never block the redirect.

Staff+

Producing a correct design is still the bar, that does not change. What differentiates a staff+ answer is also noticing which of the design's assumptions are the ones holding it up.

Establish in the first three minutes that the mapping never changes, then use it. It justifies infinite cache TTLs, background copying with no conflicts, and edge fan-out. It also locates the hard problems, which are exactly the places it breaks.

Make uniqueness structural rather than checked. Arriving at a keyed permutation over a counter, and naming the key as a permanent secret you can never rotate, is the strongest single technical move available here.

Find a second use for a property you already have. Running the permutation backwards to reject requests for codes that were never issued is the payoff. Noticing that the high water mark must be rounded up rather than down is what separates having heard of the idea from having thought it through.

Raise abuse before you are asked, and connect it to the architecture rather than treating it as policy. The link_state table, the edge denylist, the takedown target and the status code choice all follow from it.

Notice that analytics is 100 times the write volume of the system everyone else spent the interview designing, and that it is the only tier with a real scaling story. Four origin instances serve 10 billion redirects a day once the edge is doing its job.

Be able to say what you would not build. No collapsing identical URLs onto one code. No reuse of expired codes, ever. No analytics write on the request path. No cross-region coordination except for custom aliases. No hard deletes. Each of those is a real thing a team could ship and regret, and naming them as deliberate omissions is worth more than another box on the diagram.


Sources

The numbers and claims above trace back to these, and each is worth reading in full.

  • Martin Georgiev and Vitaly Shmatikov, Gone in Six Characters: Short URLs Considered Harmful for Cloud Services, 2016. The brute-force scan of 5 and 6 character token spaces that turned private OneDrive documents and identifying travel history into public data. The reason unguessability is a hard requirement rather than a preference.
  • NIST, SP 800-38G, Methods for Format-Preserving Encryption. Specifies FF1 and FF3, the ten round Feistel construction, and, in Revision 1, the one million minimum domain size that our 3.52 trillion code space clears comfortably.
  • Bitly, How does a shortened link work. Their support documentation describes their redirects as permanent, on the grounds that links are never reused or modified. A coherent position, and a useful counterpoint to the argument made above for 302.

Spot a mistake, or something unclear? . Or get in touch.

Article

Send feedback

Thank you for helping. We read every report and fix what is wrong.

How do you feel about oneby7?

Sign in to oneby7

Keep your progress, your streak and your code, on any device.

New here? This creates your account too.