Link shortener — a full worked answer
The whole thing, start to finish, in the order you would actually say it. Read it to learn the shape of a strong answer — then go and produce one yourself, because reading is not the same skill as producing.
The question
Design a service that turns a long URL into a short one, and sends anyone who visits the short link to the original. Anyone can create links; links do not expire by default.
Read-heavy / caching. Few people write. Many read, and they read the same few things.
Your estimate comes back with reads outnumbering writes by 50x or more. The write path is a non-problem and you should say so out loud instead of designing it.
Before anything: where is the slack?
How long the system has before a person notices. Ask this before you draw a single box — it decides more of the design than scale does.
A person is standing in front of a blank browser tab waiting to be redirected. There is nowhere to hide latency on the read path.
The redirect has zero slack. It is the product, it happens before any page renders, and a human is watching a spinner. Two hundred milliseconds here is noticeable; a second feels broken. So the read side of this problem is mostly a caching problem.
The write path has plenty of slack, and candidates often forget to use it. A person creates a link once, on purpose, and waiting 300 ms for it is fine. So on writes you can afford a database round trip, a uniqueness check, even a retry. On reads you can afford none of that.
Analytics has a lot of slack. A creator does not need their click count within a second of the click. So counting can be asynchronous, batched, approximate, and moved off the redirect path. That matters, because otherwise every redirect becomes a write.
So: The slack profile decides the design: the redirect is served from memory with no database on the path, creation is allowed to be slow and careful, and click counting goes into a queue and is aggregated later. Get those three budgets right and the rest is bookkeeping.
Requirements
about 5 minWhy this stage exists: Because the question is deliberately vague, and every box you draw before this is a guess.
Assumptions, said in one breath: global users, a few hundred million links total, reads massively outnumber writes, auth exists but is not what we are designing, and links are permanent unless someone deletes one.
One question that changes a box: can users pick their own short code? If yes, I need a uniqueness check on a user-supplied string on the write path, which is a different write design from generating a code myself. I will assume yes, custom codes are supported, because most real products offer them and it is the more interesting version.
A second question: does the click count need to be exact, or is close enough fine? Exact means a synchronous write on every redirect, which is what I least want on that path. I will assume approximate is fine.
Scope I propose: create a link, optionally with a custom code; follow a link; and rough click counts. Out of scope: editing links, expiry, link previews, and spam and malware checking. I would flag that last one as a production requirement I am setting aside on purpose.
What an interviewer is ticking off in this stage
- Stated scale, region and read/write split as assumptions rather than asking about them
- Asked about custom short codes — it changes the write path
- Asked whether click counts must be exact — it decides whether redirects write to a database
- Proposed a scope without being asked
- Named at least one thing left out of scope on purpose
Actors and lifecycle
about 6 minWhy this stage exists: Because the happy path is the easy half. The failure branches are where the design actually lives.
Actors: the creator, the visitor who clicks, and the system itself. The system is the one worth naming. It generates codes, decides whether a code is available, decides what to do with a code that does not exist, and decides which clicks count as real rather than bots.
The chain: link requested → code chosen (generated or claimed) → link stored → link served, over and over → optionally deleted or expired.
The failure branches are where the design lives. At code chosen: the generated code collides with an existing one, so retry with a new code; if it was a custom code, return "that name is taken" immediately. At stored: the write succeeds but the cache is not updated, so the first click on a new link misses. That is acceptable, because it falls through to the database. At served: the code does not exist, so return a clean 404 rather than a redirect to nowhere. Or the code exists but the destination is now dead. We cannot fix that, but we should not hide it either. At served, second branch: the destination is reported as malware after creation. We need to disable a live link and have that take effect fast, so the cache TTL is also a safety control, not only a performance one.
One more the system owns: the click that arrives while the counting queue is backed up. It should still redirect. Counting is allowed to fail; redirecting is not.
What an interviewer is ticking off in this stage
- Listed the system itself as an actor, not just the two humans
- Wrote the states as a chain from creation to serving
- Handled code collision on generation, and "taken" on custom codes
- Handled a code that does not exist — a clean 404, not a broken redirect
- Handled disabling a live link, and noticed the cache TTL is what bounds it
- Said that counting may fail but redirecting may not
The lifecycle, drawn
Numbers
about 6 minWhy this stage exists: Because one part of this is hard and the rest is easy, and they look identical until you do the arithmetic.
Assume 100 million new links a month. That is roughly 3.3 million a day. A day is 86,400 seconds (round it to 100,000), so about 40 writes per second. Peak at 3x is 120. One modest database handles that easily.
Reads: assume 100 clicks per link over its life, which gives about 4,000 reads per second average, call it 12,000 at peak. Also not enormous, but it is a hundred times the write rate, and that ratio drives the design.
Storage: a row is roughly a 7-character code, a URL averaging 100 bytes, a user id, a timestamp, a flag. Call it 200 bytes with overhead. 100 million a month is 20 GB a month, 240 GB a year. After five years that is about 1.2 TB, which fits on one machine. So this is not a storage problem either.
So the hard part here is read latency, not throughput and not size. Everything I build should be aimed at answering a redirect from memory. The second hard part is code generation without collisions, because that is where correctness can break.
What an interviewer is ticking off in this stage
- Produced a writes-per-second number with the assumption behind it stated
- Produced a reads-per-second number and named the read:write ratio
- Estimated storage and concluded it fits on one machine
- Explicitly said write sharding is not needed — the numbers ruled it out
- Finished the sentence: the hard part is read latency and code collision
The numbers, drawn
So the hard part is read latency, not write throughput and not storage. Design the redirect to answer from memory; leave the write path simple and synchronous.
High-level design
about 12 minWhy this stage exists: Because now — and only now — you have a reason for every box you are about to draw.
Read path: client → CDN or edge → redirect service → cache → database, with the cache expected to answer almost everything. Justification per box: the edge is there because clicks come from everywhere and a cross-region round trip is 150 ms, which is most of my latency budget. The cache is there because 4,000 reads a second against 40 writes a second means the same links are requested again and again, and the data is tiny. The database is there because the cache is not durable.
Write path: client → creation service → database, synchronously, then a cache write. At 40 writes a second I do not need anything clever, and the user creating a link can wait 200 ms for a proper uniqueness check.
Code generation is the main decision. Three options. Hash the URL and take the first 7 characters: same URL gives the same code, which is nice, but collisions must still be handled and two users cannot get different links to the same destination. Random 7 characters from a 62-character alphabet: 62^7 is about 3.5 trillion possibilities. Even after five years (6 billion links) a new code collides less than 0.2% of the time, so insert with a unique constraint and retry on failure. That is simple and correct. Or a counter encoded in base62: no collisions at all by construction, but a single global counter is a coordination point, and sequential codes are guessable, which leaks how many links exist and lets someone enumerate them. I would take random-with-unique-constraint: the retry rate stays negligible, there is no coordination, and codes are not guessable. Cost: a tiny probability of a retry on write, which I have plenty of budget for.
Data model: one table keyed by code, holding destination, owner, created time, disabled flag. The key is the code because every read is a lookup by code. Custom codes and generated codes share the same namespace and the same unique constraint, so "that name is taken" comes for free.
Analytics: the redirect puts a click event on a queue and returns immediately. It does not wait, and it does not write to the database. A consumer aggregates counts. The Stage 1 assumption that counts can be approximate is what allows this, and it keeps the redirect path read-only.
A 301 versus 302 decision worth naming: 301 is permanent and browsers cache it, often indefinitely. Repeat clicks become free, but you cannot count them, and a changed or disabled destination does not reach browsers that already cached it. 302 keeps every click coming to you. I would use 302, because click counting and the ability to disable a bad link are both requirements, and I am accepting the extra traffic that costs.
What an interviewer is ticking off in this stage
- Every box has a stated reason — a requirement or a number
- Cache sits in front of the database on the read path
- Chose a code generation strategy and named the alternatives
- Handled collisions with a unique constraint and a retry, not with hope
- Data model keyed by the short code, because that is the only read pattern
- Click counting moved off the redirect path onto a queue
- Made the 301 vs 302 decision on purpose
The architecture, drawn
Deep dive and tradeoffs
about 14 minWhy this stage exists: Because naming what a decision costs is the single clearest signal of seniority there is.
Hard part one: keeping the redirect from waiting on a database. At 200 bytes a row, the working set of popular links fits in memory easily. Cache-aside in Redis, keyed by code, with a long TTL because links almost never change. On top of that, a small in-process cache on each redirect server for the hottest few thousand codes removes even the Redis hop for the most-clicked links. Cost: a link disabled for malware stays live for as long as the longest TTL in the stack, so I would keep the in-process TTL short (30 seconds), delete the Redis entry, and publish an invalidation message on disable. I am accepting up to 30 seconds of a bad link being live in exchange for removing a network hop from the hot path.
The empty-cache case is the one that takes services down. If Redis restarts empty, every redirect goes to the database at once: 12,000 a second at peak against a database sized for 40 writes. Two defences: coalesce misses so a thousand simultaneous requests for the same code produce one database read, and warm the cache from the top codes before taking traffic. Cost: coalescing adds a lock per key and a small latency penalty on the miss path.
A request for a code that does not exist is not cached by plain cache-aside, because there is nothing to store. So an attacker requesting random codes drives every request to the database. Cache the negative result for a short window, or put a bloom filter of existing codes in front. Cost: a newly created link may be briefly reported as missing if the filter is stale, so the filter is updated on write and the negative cache TTL is kept to seconds.
Hard part two: correctness on creation. The unique constraint on the code column is what guarantees no two links share a code. Application logic cannot, because it has a race between checking and inserting. Insert, catch the constraint violation, generate a new code, retry up to a few times. For custom codes, the same constraint gives "that name is taken" with no extra code. I also need a reserved list so nobody claims /api, /login or /settings. Cost: the reserved list must be updated every time the product adds a top-level route, and it is easy to forget.
Abuse, which an interviewer is likely to raise and which I would raise myself: this service is an excellent tool for hiding malicious destinations. Rate limit creation per account and per IP, check destinations against a reputation service asynchronously after creation, and make disabling a link fast and cache-aware. Cost: the async check means a bad link is live for a short window. I accept that, because a synchronous check would put a third-party call on the creation path and make creation fail whenever that service does.
Scaling later, said as a plan rather than a build: redirect servers are stateless so they scale horizontally behind a balancer; the database gets read replicas long before it needs partitioning; and if it ever does, the code is a good shard key because every read and every write is keyed by it. Cost of that future move: the data migration itself, but no query or data model changes, because the key was chosen on day one.
What an interviewer is ticking off in this stage
- Layered cache with a stated TTL, and named what the TTL costs on the disable path
- Handled the empty or dead cache — coalescing and warming
- Handled lookups for codes that do not exist — negative caching or a bloom filter
- Uniqueness enforced by a database constraint, not by check-then-insert
- Reserved words for custom codes
- Named the abuse problem and the rate limit, unprompted
- Said what future partitioning would look like and why the key already works
- Named a cost after every decision
The decision this turns on
- —3.5 trillion possibilities at 7 characters, so collisions are rare.
- —No global coordination: any server can generate one.
- —Codes are not guessable, so nobody can enumerate your links.
- —Needs a retry loop on the rare constraint violation.
- —No collisions at all, by construction.
- —Shortest possible codes, since no space is wasted.
- —The counter is a coordination point: a single sequence every server needs.
- —Sequential codes are enumerable, leaking both your links and your volume.
And then they push back
A complete answer is where the interview starts, not where it ends. These are the questions this design invites — the interview is decided here far more often than in the diagram.
10x traffic tomorrow. What fails first?
"We would add more servers and scale horizontally." This says nothing. Every system has a first thing to break, and not naming it means you do not know your own design well enough to have found it.
Name a specific component and the reason. "The stateless services scale out fine, so they are not the constraint. The database primary is, because every write goes through one machine and I am at roughly 40% of its write capacity now. So at 4x I am at the ceiling, and I would see write latency climb and connection pool waits before anything errors. The second thing is the cache, because 10x reads on a fixed working set is fine but 10x on a growing key space means my hit rate drops and the misses land on the database that is already the constraint. In order: database writes, then cache hit rate, then the connection pool. And I would find out from p99 write latency, not from errors."
The cache just died. What happens?
"Requests would go to the database instead, so it would be a bit slower." At the ratios that justify a cache, the database cannot take that load. This answer means you added a cache without checking what it was protecting you from.
"Every read that was being served from memory lands on the database at once. My cache was absorbing something like 99% of reads, so the database sees roughly 100x its normal load, and it will not survive that. A naive design goes down. What I would do about it, designed in advance: coalesce misses so a thousand simultaneous requests for the same key produce one database read rather than a thousand; keep a small in-process cache on each server as a second layer so the hottest keys never leave the machine; shed load at the edge rather than let the database die, because a degraded site beats a dead one; and bring the cache back warm rather than empty, because restarting into an empty cache reproduces the outage. Cost of the in-process layer: servers can briefly disagree with each other."
The write worked but the response was lost and the client retried. What happens?
"The client would get an error and could try again." They already tried again. That is the premise. If the retry does the work a second time, you have charged the card twice.
"With idempotency keys, nothing bad: the retry carries the same key, the server finds it already claimed, and returns the original stored response, so the client sees success and the card is charged once. Without them, two charges and a customer complaint. The implementation detail that makes it airtight is inserting the key with a unique constraint in the same transaction as the work, because two separate steps leave a crash window between them. Two things people get wrong: the client must reuse the key across retries rather than generating a fresh one each attempt, which defeats the scheme without raising any error; and if the retry arrives while the first request is still running, I return an in-progress status so the client backs off, rather than doing the work again or returning a wrong answer. Keys expire after a day or so, or the table grows forever."
Why this database and not Postgres?
"It scales better and is designed for high throughput." Vague and unfalsifiable. Postgres handles far more than most people assume, and an answer with no number in it sounds like a preference.
"The answer has to be a number or an access pattern. Something like: the write rate is around 80,000 a second sustained on one logical table, append-only, with no cross-row transactions. That is more than I would plan to commit on one Postgres primary, so I would be sharding it myself and hand-building rebalancing and failover that a distributed store already has. The read pattern is a single-key lookup, so I am giving up joins I do not need. And then what it costs: no cross-partition transactions, so any invariant spanning partitions moves into application code, and analytics needs a separate copy of the data. If I cannot produce a sentence like that, then the correct answer is that Postgres is fine. Saying so is the stronger answer: choosing the boring option on purpose shows more judgement than reaching for the distributed one."
What does this cost per month?
"I am not sure, it depends on the provider." Interviewers rarely want exact prices. They want to know whether you understand which line item dominates, and that is answerable from your own estimates.
"I would not guess at prices, but I can tell you the shape, which is what matters. The dominant cost here is egress bandwidth: my estimate said 1,350 GB a second to viewers, and at that volume nothing else on the bill comes close. Second is storage, because I keep raw data for two years and it grows every day whether anyone reads it or not. Third is compute, which is elastic and therefore the easiest to control. That ordering tells me where to spend engineering effort: a percentage point of CDN hit rate is worth more than any amount of compute optimisation, so cache hit rate is the number I would put on a dashboard and defend. Storage cost I would attack with lifecycle policies moving cold data to cheaper tiers and with tiered granularity. Compute I would run on interruptible capacity because the work has hours of slack."
One user reports a slow request. How do you debug it?
"I would check the logs." Which logs, for what? At scale, "check the logs" without a way to find that specific request is a way of saying you have not thought about observability.
"Work down rather than guessing. First: is it slow for everyone or just them? A dashboard of that endpoint's p50 and p99 answers it in seconds — if p99 is bad and p50 is fine, it is probably data-dependent, meaning that user has far more rows than the median. Second: get a trace of their actual request, which requires request ids propagated through every service, and see whether the time went to one slow call or two hundred fast ones. Two hundred fast ones is an N+1, which is invisible in development and common in production. Third: if it is one slow query, run EXPLAIN with their parameters, not typical ones, because the plan can differ once their row count crosses a threshold. The common outcome is that they are a heavy account and the query has no index for their filter combination. What makes this possible at all is having decided beforehand to propagate request ids and to log with enough structure to find one user's request among billions."
Now make it multi-region.
"We would replicate the database to another region." That covers reads. The question is what happens to writes, and that is where the design actually changes.
"Reads are the easy half: replicas in each region, users read locally, and they may be slightly stale — I would say how stale and which reads cannot tolerate it. Writes are the hard part, and there are three options. Single write region: simplest, no conflicts, and users far away pay 150 ms per write, which is fine if writes are rare and unacceptable if they are not. A home region per data partition, so each user's data lives near them: local writes without conflicts, but it needs a routing layer plus a plan for users who move. Multi-leader with conflict resolution: local writes everywhere, and now I own a conflict problem, which is only acceptable where merges are safe. I would pick the second for user-owned data and single-region for anything globally contested like inventory. What changes elsewhere: cross-region replication cost, a failover story per region, and data residency rules that may force certain data to stay in certain places. I would ask about that constraint rather than discover it later."
A whole region is down. What do users see?
"We failover to another region." That is the mechanism, not the answer. The question is what a user experiences, for how long, and what is lost.
"I would answer per path rather than globally. Reads: if replicas exist elsewhere, they keep working, possibly stale, and I would say how stale. Writes: if the leader was in the dead region, writes fail until a new leader is promoted. That takes seconds to a minute, and being aggressive about promotion risks promoting during a brief network blip and ending up with two leaders. For contested resources like inventory I would rather be unavailable for that region's writes than risk selling the same thing twice, and I would say that explicitly as a choice. For non-contested writes like posting a comment, take it locally and reconcile. Data loss: with async replication, anything committed in the last few seconds and not yet replicated is gone, so I would use semi-synchronous replication for anything I cannot lose. And DNS-based failover is slow because clients cache DNS for minutes, so I would not rely on it alone."
The topics this answer used
You have read a good answer. That is worth very little until you can produce one on a blank page — so go and write this problem without looking, and compare afterwards.