Skip to content

The finished design, decision by decision

How to design a URL Shortener

Not the one correct diagram, but a design you can defend under these constraints: the finished architecture, then every stage's question with the reasoning that answers it, the tradeoffs it accepts, and where another engineer could land differently.

A URL shortener like bit.ly

The short answer

8 parts, each with one job. The map below shows how requests and data move between them; the stages after it explain why each part is there.

Clickers
Follow short links from anywhere.
CDN edge
Caches redirect responses close to users for a short TTL; purged on takedown.
Redirect service
Looks up a code, returns a 302, and emits a click event without waiting.
Postgres
Links (code primary key, destination, owner, status) and aggregated click counts.
Links API
Creates links with random codes, edits and disables them.
Customer dashboard
Creates links and reads analytics.
Click stream
Buffers click events durably.
Click aggregator
Counts clicks per link, day and country in batches.
12345678CLIENTClickersEDGECDN edgeSERVICERedirect serviceDATABASEPostgresSERVICELinks APICLIENTCustomerdashboardLOG / STREAMClick streamWORKERClick aggregator

Select a component to see what it is responsible for and which state it owns.

  1. 1Clickers → CDN edge: GET /aZ3kQ9x
  2. 2CDN edge → Redirect service: Cache miss
  3. 3Redirect service → Postgres: Look up code
  4. 4Customer dashboard → Links API: Create, edit, disable
  5. 5Links API → Postgres: Insert with unique code
  6. 6Redirect service → Click stream: Click event (fire and forget)
  7. 7Click aggregator → Click stream: Read click batches
  8. 8Click aggregator → Postgres: Upsert daily aggregates
  • Request / response
  • Asynchronous

Why does this design work?

The numbers fit on one relational database, so the links live in Postgres with the code as the primary key, and the database enforces uniqueness. Codes are random, so they can't be guessed, and the rare collision becomes a retry.

Redirects are 302s cached briefly at CDN edge servers, so popular links are answered near users while edits and takedowns still work through purges and short TTLs. Clicks are sent to a stream and counted in batches, so analytics never slows redirects. Where regional replicas serve less popular links, a miss is checked against the primary and "not found" is cached only briefly.

Invariants, and where they are enforced

  • Every code maps to exactly one link.

    The code is the primary key; creation inserts a random code and retries on the (rare) conflict. Enforced by Postgres, Links API.

  • A disabled link stops redirecting within seconds, everywhere.

    Short edge TTLs plus an explicit CDN purge on disable, with 302 responses so browsers do not cache the redirect permanently. Enforced by Links API, CDN edge.

  • Redirects never wait on analytics.

    Click events are emitted asynchronously to a buffer; aggregation happens in batches elsewhere. Enforced by Redirect service, Click stream.

What does it rely on?

  • Clicks are uneven enough for edge caches to absorb most reads of popular links.
  • The CDN's purge API works within seconds.
  • Clients follow 302 redirects and respect Cache-Control.
  • Analytics may lag by minutes.

What tradeoffs does it make?

ChoiceGainsCosts
302 with short edge TTLsCountable clicks, editable links, fast takedowns.More traffic reaches the origin than with permanent redirects.
Random codes + unique keyUnguessable, no coordination, uniqueness enforced by the database.Inserts scattered across the index; occasional retries.
Asynchronous click countingRedirects never wait on analytics.Counts lag; a stream and a worker to run.
Replica reads with primary fallbackFast redirects in other regions for every link.Cross-region trips on misses; lag handling in code.

What are the reasonable alternatives?

Redirects served entirely at the edge (a key-value store and functions on the CDN)
Better when every link, including rarely clicked ones, must be fast everywhere, and the platform's consistency model fits.
Partitioned key-value store for links
Better when link volume or write rates outgrow a single primary.
Log-based analytics only
Better when most redirects are served at the edge, so per-click events from the origin would undercount anyway.

When does it stop working?

  • Link volume outgrows one machine's storage, which calls for partitioning by code.
  • Customers need exact, real-time click counts, including clicks answered by the CDN.
  • Regulations require data to stay in each region, so links must be created and stored regionally.
  • Abusers create links very quickly, which needs rate limits on creation and scanning before a link goes live.

Every stage, decided and explained

Spoilers, for the whole investigation: each stage's question and its answer, the reasoning behind it, and the tradeoffs it accepts. If you have not worked through the stages yet, you may want to do that first.

Work through the stages

Stage 1 of 9 · Model

Estimate the load

Before drawing any boxes, work out how much traffic and data this system really has. Rough numbers are enough: you only need to know whether something fits on one machine or needs many.

What you need to know first

Requirements usually come as monthly or daily totals. Systems fail per second, so convert.

A month has about 2.6 million seconds (30 days × 86,400 seconds). Divide a monthly total by 2.6 million to get the average per second. Traffic isn't flat, so multiply by the peak factor to get the rate you actually have to handle.

100 million new links a month. About how many links are created per second, on average?

About 38 per second.

100,000,000 ÷ 2,600,000 ≈ 38 per second. At 5× peak, about 190 per second.

One Postgres server handles thousands of small inserts a second, so writes are nowhere near a limit.

10 billion redirects a month, with peaks at 5× the average. About how many redirects per second at peak?

About 19,000 per second.

10,000,000,000 ÷ 2,600,000 ≈ 3,850 per second on average. × 5 ≈ 19,000 per second at peak.

That's about 100 reads for every write. This is a read-heavy system, so the redirect path is where the design effort goes.

Now storage. Estimate the size of one record, then multiply by how many you add per year.

A link row holds a 7-character code, the destination URL (usually 100 to 200 bytes, sometimes much longer), an owner id, timestamps and a status. With index overhead, 500 bytes is a reasonable round number. 100 million links a month is 1.2 billion a year.

1.2 billion links a year at 500 bytes each. About how much storage per year?

About 600 GB.

1,200,000,000 × 500 bytes = 600,000,000,000 bytes ≈ 600 GB a year.

A single database server can hold several terabytes, so this fits on one machine for years.

Compare your numbers with what one machine can do. These are rough figures for a well-provisioned server, worth remembering as orders of magnitude:

WorkOne server handles roughly
Postgres lookups by primary keytens of thousands per second
Postgres small insertsthousands per second
Redis getsaround 100,000 per second
Diskseveral terabytes

Put your three numbers (190 writes/s at peak, 19,000 reads/s at peak, 600 GB/year) next to that table. What do they tell you?

One database server can handle it, especially with a cache in front for popular links.

Every number is within one server's range. That means scale isn't the hard part of this problem. The hard parts are elsewhere: correct codes, distant users, counting clicks and takedowns.

What the stage asks

Where should the links be stored?

  1. Sound

    One Postgres primary with a standby replica for failover, with the code as the primary key

    It covers the numbers with room to spare, and the primary key gives you uniqueness for free. The standby protects against losing the server. You can add read replicas or partitioning later, when a measurement says you need them.

  2. Defensible

    A Cassandra cluster partitioned by code

    It would handle far more than this, but you pay to run a cluster, and you lose simple unique constraints (Cassandra's lightweight transactions can emulate them, at a latency cost). Pick it when writes or storage outgrow one machine. These numbers are about 100× away from that.

  3. Flawed

    Redis as the only store, since every read is a key lookup

    Lookups would be fast, but Redis keeps everything in memory (600 GB a year of RAM is expensive) and its usual persistence settings can lose the last second of writes in a crash. A lost link breaks every poster it's printed on. Redis works well as a cache in front of a durable store, not instead of one.

  4. Defensible

    Postgres sharded across eight servers from day one

    It works, and code-based sharding is a reasonable plan for later. Today it means eight servers to run and back up, and routing logic in the app, to hold data that fits on one.

What a strong answer covers

  • Uses the rates: about 40 writes/s (190 at peak) and about 19,000 reads/s at peak.
  • Uses the storage estimate: about 600 GB a year.
  • Names what would justify distributing, such as storage beyond one machine or write rates beyond one primary.Supporting

The reasoning

  1. Divide monthly totals by 2.6 million to get per-second rates, then multiply by the peak factor.
  2. Compare each number with what one machine can do before you reach for a distributed database.
  3. When the numbers fit on one machine, the hard parts of the problem are correctness and latency, not scale.

Writes are about 40 a second, reads about 19,000 at peak, and storage grows about 600 GB a year. One database with a cache in front handles all of that.

So the rest of this investigation isn't about scaling. It covers four problems the numbers don't solve: generating codes correctly, serving users far from the servers, counting clicks without slowing redirects, and removing a bad link from every cache.

Stage 2 of 9 · Decide

Generate short codes

Every link needs a code like aZ3kQ9x. Codes must be short, two links must never share one, and knowing one code must not let anyone find others.

What you need to know first

Codes use base62: the characters a–z, A–Z and 0–9. Each character has 62 possible values, so a code of n characters has 62ⁿ possible values.

LengthPossible codes
5about 916 million
6about 57 billion
7about 3.5 trillion

You'll create about 1.2 billion links a year, and codes are chosen at random. Which length should you use?

7 characters

After 10 years, 12 billion of 3.5 trillion codes are taken: about 0.3%. Roughly one new code in 300 collides, and a single retry fixes it.

There are three common ways to produce a code.

  1. Counter. Keep an auto-incrementing number and write it in base62. Link 1 gets 1, link 125 gets 21, and so on.
  2. Hash. Hash the long URL (with SHA-256, say) and keep the first 7 characters. The same URL always gives the same code.
  3. Random. Pick 7 random characters from a cryptographically secure generator.

Codes come from a counter, and you know one link: sho.rt/aZ3kQ9x. What could you do with it?

Decode it to a number and try the numbers next to it. You can walk through every link anyone has made, including private ones like unreleased campaign pages. Comparing two codes a week apart also tells a competitor how many links the company makes per week.

That breaks the requirement that codes can't be enumerated.

With hashing, two different customers shorten the same URL, https://shop.com/sale. What happens?

They get the same code, so they share one link.

They now share click counts, and if one customer edits or deletes the link, the other customer's link changes too. A hash identifies the destination, but a link belongs to one customer.

That leaves random codes. They reveal nothing and need no coordination between servers, but two creations can pick the same code.

Don't try to prevent that by checking first. Make the code the table's primary key and just insert. If the code is taken, the database rejects the insert, all in one atomic step, and you try again with a new random code.

Why not run SELECT … WHERE code = $1 first, and only insert if the code is free?

Two requests can both check, both see the code is free, and both insert.

The check and the insert are separate steps, and another request can run between them. Only the insert is atomic, so let the insert do the checking.

What the stage asks

How should codes be generated?

  1. Flawed

    Hash the long URL and take the first 7 base62 characters

    Two different URLs can share a 7-character prefix, so you still need collision handling. And two customers shortening the same URL get the same code, so they share analytics and one's edits affect the other.

  2. Defensible

    Base62-encode an auto-increment id

    Unique by construction and compact. But anyone can enumerate every link and estimate your volume. Fine for an internal tool; wrong for a public product with this requirement.

  3. Sound

    Random 7-character base62 code from a secure generator; insert with the code as primary key, retry on conflict

    Unguessable, no coordination between servers, and the database enforces uniqueness. A collision is a rejected insert followed by a new random code, rare enough that nobody notices the extra millisecond.

  4. Defensible

    Pre-generate unused random codes into a pool table and hand them out

    It moves collision handling offline, which helps at much higher write rates. At about 40 writes a second it's an extra moving part that fixes nothing. Insert-and-retry is simpler and just as correct.

What a strong answer covers

  • Uniqueness is enforced atomically by the database (primary key or unique constraint), with a retry on conflict.
  • Considers enumeration: sequential codes reveal other links.
  • Recognizes that hashing the URL merges links that different customers expect to be separate.Supporting

The reasoning

  1. Let the database enforce uniqueness: make the code the primary key, insert, and retry on conflict.
  2. Sequential codes are unique but anyone can enumerate them; URL hashes merge links that belong to different customers.
  3. Custom aliases use the same insert. A conflict there means "taken", which you show to the customer instead of retrying.

Random codes make collisions rare; the primary key makes them harmless. INSERT … VALUES ($code, …) either succeeds or fails as one step, so no two requests can both get the same code. See Generating unique identifiers.

Stage 3 of 9 · Break it

Debug: a customer got someone else's link

Use what you learned in the last stage. Find every line that can produce a wrong or shared code.

What you need to know first

When reading code that creates something unique, ask three questions of every line:

  1. Where does the identity come from? Is it unique to this caller's thing, or could two callers produce the same one?
  2. What happens if it already exists? Is the existing thing really the caller's?
  3. What can happen between the check and the act? Another request can run in that gap.

Two requests run findByCode(code) at the same moment, both get nothing back, and both call insert. Without a unique constraint on code, what's in the table?

Two rows with the same code

Each request's check was true when it ran. Only the database, deciding atomically at insert time, can stop the second one.

What the stage asks

Select the faulty lines.

CodetypescriptPOST /links
  1. 1async function createLink(url: string, ownerId: string) {
  2. 2 const code = base62(sha256(url)).slice(0, 7);

    The code comes from the URL alone: two customers shortening the same URL get the same code, and truncating the hash lets different URLs collide.

  3. 3 const existing = await db.links.findByCode(code);
  4. 4 if (existing) return { code: existing.code };

    Returns an existing link without checking that it has the same URL and owner. On a collision, customer B receives customer A's link: exactly this incident.

  5. 5 await db.links.insert({ code, url, ownerId });

    Check-then-insert: two concurrent requests can both see no existing row and both insert. The insert itself must be the check, with conflicts handled explicitly.

  6. 6 return { code };
  7. 7}

What the fix has to do

  • A code should identify one customer's link, not the destination URL.
  • Uniqueness is decided by an atomic insert, not a lookup beforehand.
  • A conflict leads to a new random code (or a clear 'taken' error for aliases), never to returning someone else's row.

The reasoning

  1. "If it exists, return it" treats any link with this code as the caller's link. That's how customer B got customer A's page.
  2. Generate a random code, insert it, and handle the conflict explicitly. Never look first and insert second.

The fixed version is three lines: generate a random code, try the insert, and on a unique-key violation generate another and try again. There is no separate lookup, so there's no gap for another request to slip into. This is the simplest form of Concurrency control: let a constraint make the decision.

Stage 4 of 9 · Decide

Choose the redirect status: 301 or 302

The redirect response is what every click receives. Its status code decides who may cache it and for how long, and that affects analytics, edits and takedowns.

What you need to know first

A redirect is an HTTP response with a status code and a Location header. The browser reads the header and requests the new URL. The status code also tells caches (the browser's own cache and any CDN in between) whether they may reuse this answer next time.

StatusMeaningCaching by default
301 Moved PermanentlyThis URL will always go thereBrowsers may keep it for a very long time and stop asking you
302 FoundGo there for nowNot reused unless Cache-Control allows it
308 / 307Like 301 / 302, but a POST stays a POSTSame as 301 / 302

A link was served as a 301. The customer then changes its destination. What happens to people who already clicked it once?

Their browsers keep going to the old destination, and you can't clear their caches.

A cached 301 means the browser doesn't ask your server again. There's no API to purge someone's browser cache, so the old destination can stick for months.

What else breaks if browsers cache your redirects?

Click counts: repeat clicks never reach your servers.

A click served from the browser's cache is invisible to you, so counts come out too low.

The Cache-Control header sets who may keep a copy and for how long. max-age applies to every cache, including browsers. s-maxage applies only to shared caches like a CDN, which you can purge.

So Cache-Control: max-age=0, s-maxage=60 means: browsers must ask again every time, but the CDN may answer from its copy for up to 60 seconds.

What the stage asks

Which response should a redirect return?

  1. Flawed

    301 Moved Permanently

    Browsers may cache a 301 indefinitely and stop asking you. Later clicks from that browser aren't counted, and edits and takedowns never reach it. This product can't promise a link is permanent.

  2. Sound

    302 Found, with Cache-Control allowing only short caching at the CDN

    Browsers ask again on every click, so every click can be counted and edits and takedowns take effect. The CDN may still hold the response for a short TTL that you control and can purge, which keeps most of the speed.

  3. Defensible

    200 with a page that redirects via JavaScript or meta refresh

    Some shorteners do this to show an interstitial page or run tracking scripts. It's slower, fails without JavaScript, and search engines handle it worse. Use it only if you need the interstitial.

  4. Flawed

    308 Permanent Redirect

    It has the same problem as 301: browsers treat it as permanent. It only differs in keeping the HTTP method.

What a strong answer covers

  • Permanent redirects can be cached by browsers indefinitely, so you lose control of them.
  • Edits, takedowns and click counts all need requests to keep reaching you (or a cache you can purge).
  • Short caching at the CDN keeps most of the speed benefit.Supporting

The reasoning

  1. Use a 302 for anything that might change: browsers don't keep it, so edits, takedowns and click counts keep working.
  2. Let only caches you can purge keep copies: s-maxage for the CDN, max-age=0 for browsers.

Every cached copy is one you have to be able to update or delete later; see Caching. A 301 puts copies in millions of browsers you can't reach. A 302 with a short s-maxage keeps copies only in your CDN, which has a purge API.

Stage 5 of 9 · Decide

Make redirects fast far from the servers

Nothing is overloaded, but users in Sydney are waiting. Work out why before choosing a fix.

What you need to know first

Two different things make a request slow:

  • Load: a component is too busy, so requests queue. You see high CPU, long queues or many open connections.
  • Latency from distance: data takes time to travel. Light in optical fibre covers about 200 km per millisecond, and nothing goes faster.

They need different fixes, so diagnose first.

Sydney to the US East Coast is about 16,000 km. What is the fastest possible round trip, there and back, in milliseconds?

About 160 ms.

16,000 km ÷ 200 km/ms = 80 ms each way, so 160 ms for a round trip, before any server does any work.

Real cable routes aren't straight lines, so in practice it's more like 200 ms. A brand-new HTTPS connection needs extra round trips to set up, which is how a single redirect reaches 280 ms or more.

The database is at 20% CPU, the redirect service at 30%, and Sydney sees 280 ms. What's the bottleneck?

Distance: the requests cross the Pacific and back.

Low CPU means the servers aren't busy. Most of the 280 ms is spent travelling.

The only fix for distance is to answer from somewhere closer:

  • Cache at the edge. A CDN has servers in Sydney. They can keep a copy of a redirect for a short time and answer it locally in a few milliseconds.
  • Run a copy of the service nearby. Put redirect servers and a read replica of the database in Asia-Pacific.

What the stage asks

What do you change?

  1. Flawed

    Upgrade to a larger database instance

    The database is at 20% CPU. The time goes into crossing the Pacific twice, and a faster database doesn't shorten that trip.

  2. Flawed

    Put a Redis cache in front of Postgres in the US region

    It saves a millisecond or two of lookup time and none of the 200+ ms of travel. A cache only helps latency if it's close to the reader.

  3. Sound

    Cache redirect responses at the CDN edge with a short TTL (say 60 s), purging on edit or disable

    A popular link is answered from a CDN server in Sydney in a few milliseconds; only the first request per edge server per TTL goes to the US. Since a few links get most clicks, that covers this campaign. Short TTLs plus purges keep edits and takedowns prompt.

  4. Defensible

    Deploy redirect services and Postgres read replicas in Asia-Pacific

    It speeds up every link, not just popular ones, but you run a second region and have to deal with replication lag (stage 8). Worth it when the less popular links matter there too.

What a strong answer covers

  • Identifies distance (network round trips), not load, as the cause.
  • Only answering from closer to the user (edge cache or regional copy) fixes it.
  • Uses the fact that a few links get most clicks to justify caching them.Supporting

The reasoning

  1. Check utilisation before optimising: low CPU plus slow responses points to distance, not load.
  2. Distance is only fixed by answering closer to the user: an edge cache or a regional copy.
  3. Edge caching works best when a few items get most of the requests, as popular links do.

A bigger database or a cache in the US region speeds up the part that was already fast. The 280 ms is travel time, so the answer has to come from a server near Sydney.

Edge caching means copies of redirects now exist on CDN servers around the world. Stage 7 tests what that means when a link has to be removed.

Stage 6 of 9 · Decide

Count clicks without slowing redirects

Customers want clicks per link, per day, per country. A popular link may get 5,000 clicks a second during a campaign. Counts may lag by a minute or two.

What you need to know first

The obvious approach is to update a counter during each redirect:

UPDATE links SET clicks = clicks + 1 WHERE code = 'aZ3kQ9x';

An UPDATE locks the row until its transaction commits, so two updates to the same row take turns.

A link gets 5,000 clicks a second, and every click runs that UPDATE on the same row. What happens to redirects for that link?

The updates queue for the same row lock, one after another. Each redirect waits behind all the others, so redirects for that link slow down and start timing out.

A feature that only needed approximate counts has broken the main product.

The fix has two parts:

  1. Don't wait. The redirect appends a click event to a durable stream (like Kafka or Kinesis) and responds immediately. See Asynchronous processing.
  2. Batch. A separate worker reads events in batches, adds them up per link, day and country, and writes one total per group.

The worker flushes every 10 seconds. For the link getting 5,000 clicks a second, how many clicks does each write now cover?

About 50,000 clicks per write.

5,000 clicks/s × 10 s = 50,000 clicks in one batch, written as a single +50,000 to that link's row. 50,000 lock waits become one.

During a huge campaign the worker falls behind and the stream builds a backlog. What do users notice?

Click counts are behind for a while. Redirects stay fast.

The redirect only appends an event and never waits for the worker. The backlog only delays the counts, which customers accept can lag.

One catch: if the CDN answers a redirect from its cache, the request never reaches your redirect service, so no event is emitted. With edge caching, exact counts have to come from the CDN's logs or from code running on the CDN.

What the stage asks

How should clicks be counted?

  1. Flawed

    UPDATE links SET clicks = clicks + 1 in the redirect request

    Every redirect becomes a write, and a popular link becomes one row receiving 5,000 updates a second. Lock contention slows that link's redirects, so a problem in analytics turns into a redirect outage.

  2. Sound

    Emit a click event asynchronously to a durable stream; a worker counts in batches and upserts daily totals

    The redirect doesn't wait for analytics, and the database gets one write per (link, day, country) per batch instead of one per click. The stream absorbs spikes; if the worker falls behind, counts lag but redirects stay fast.

  3. Defensible

    Derive counts from CDN and server access logs processed hourly

    It needs no code on the redirect path, and it counts clicks the CDN answered, which the origin never sees. The costs are hourly freshness and a log pipeline to run. Many shorteners that cache at the edge end up doing this.

  4. Defensible

    Count 1% of clicks and multiply by 100

    Cheap and fine for large numbers, but a customer with 37 clicks sees 0 or 100, and you can't bill by clicks. Sampling suits overall dashboards, not per-link counts that customers rely on.

What a strong answer covers

  • Counting happens asynchronously, so redirects never wait on it.
  • Batching turns per-click writes into a small number of writes.
  • Notes that redirects answered by the CDN never reach the origin, so their clicks need CDN logs or edge events.Supporting

The reasoning

  1. Never make the main request wait for bookkeeping: emit an event and respond.
  2. Batching turns thousands of writes to one hot row into one write per batch.
  3. If a cache answers requests, those requests are invisible to your servers. Plan how to count them.

The work of counting is tiny. The problem was that it competed with redirects for the same row. Moving it to a stream and a batch worker removes that competition: redirects only append, and the database sees one write per link per batch.

Stage 7 of 9 · Break it

Take down a malicious link everywhere

The database now says the link is disabled. But the redirect has been copied to other places.

What you need to know first

List every place a copy of this redirect might still exist after the database row is disabled.

  • CDN edge servers, until their copy expires or you purge it.
  • Browsers, if the redirect was a 301 or had a browser max-age. You can't clear these.
  • Any cache inside the redirect service, such as Redis or in-process memory.
  • Read replicas that haven't applied the update yet (usually for under a second).

A CDN purge removes a URL from its edge servers, usually within seconds. A short TTL is the backup: if a purge fails or misses a server, the copy still expires soon.

The link gets 200,000 clicks an hour. With no purge and a 60-second CDN TTL, about how many more clicks could still reach the malware in the worst case?

About 3,300 clicks.

200,000 per hour ÷ 60 = about 3,300 clicks a minute, so one full TTL is about 3,300 more people. With a 1-hour TTL it would be 200,000. A purge cuts this to a few seconds' worth.

What the stage asks

What should disabling a link do?

  1. Flawed

    Set the status to disabled in the database

    The CDN keeps serving its cached redirect until it expires. With a 60-second TTL that's thousands more clicks to malware; with a longer TTL, far more.

  2. Sound

    Set it disabled, purge the URL from the CDN, and from then on return a 410 Gone that is itself cached briefly

    The purge removes the copies you control right away, the short TTL is a backstop if the purge misses an edge server, and the 410 tells users and crawlers the link was removed on purpose.

  3. Defensible

    Set it disabled and let the 60-second TTL expire on its own

    Exposure is capped at one TTL, which some teams accept for ordinary edits. For malware getting 3,300 clicks a minute, it's worth calling the purge API.

  4. Defensible

    Stop caching redirects at the CDN altogether

    Takedowns become instant, but every click goes back to the US region, and you're back to slow redirects in Sydney. You'd be giving up a lot to fix something a purge already fixes.

What a strong answer covers

  • Names the CDN as a place copies live after the database changes.
  • Uses an explicit purge, with a short TTL as a backstop.
  • Notes that browser caches can't be purged, which is why redirects are 302s.Supporting

The reasoning

  1. A delete has to reach every copy: database, CDN, any service caches. Browsers can't be reached at all.
  2. Purge on delete, and keep TTLs short as a backstop.
  3. Decide where copies may live before you need to delete one. That's why the status code, TTL and purge are designed together.

Disabling the row only changes the source. Every cache built in earlier stages now holds a copy that has to be removed too. Because the redirect is a 302, browsers hold no copies, and the CDN's copies can be purged. See Caching.

Stage 8 of 9 · Change it

New links show as "not found" in a new region

Replication lag is normally a few hundred milliseconds. The symptom lasts an hour.

What you need to know first

A read replica copies every change from the primary database, usually within milliseconds, but never instantly. That delay is replication lag. A link created on the primary may be missing from a replica for a moment. See Replication.

A negative cache stores "not found" answers, so repeated requests for codes that don't exist (from scanners guessing codes, for example) don't hit the database.

A customer creates a link in the US. 200 ms later someone in Sydney clicks it. The Sydney replica doesn't have it yet, so the service answers 404, and the CDN caches that 404 for an hour, like any redirect. What does everyone else in Sydney see?

"Not found" for up to an hour, even though the replica caught up within a second. The cache took a moment of lag and kept it for the whole TTL.

The Sydney replica doesn't have code aZ3kQ9x. What does that tell you?

Either the link doesn't exist, or it's new and the replica hasn't caught up yet.

A missing row on a replica is uncertain. Asking the primary settles it, and that's only needed for misses, which are rare.

What the stage asks

What do you change?

  1. Defensible

    Make replication synchronous so replicas are never behind

    It removes lag, but every link creation now waits for a trans-Pacific round trip, and a slow replica stalls writes everywhere. It's a heavy cost, and it does nothing about the hour-long cached 404.

  2. Sound

    On a replica miss, check the primary before answering 404, and cache 404s only briefly

    A miss on a replica isn't proof the link doesn't exist. Checking the primary costs a cross-region trip only for real misses and very new links. The hour came from caching the first 404 as long as a redirect; "not found" deserves a much shorter TTL.

  3. Defensible

    Route the creator's own reads to the primary for a minute after they create a link

    That's read-your-writes for the creator, but the people who click a new link are mostly not its creator. It doesn't help them.

  4. Flawed

    Cache 404s at the edge for longer to protect the database from scans

    This turns sub-second replication lag into hours of a working link being reported as missing.

What a strong answer covers

  • A replica can lag behind the primary, so a new link may be missing there briefly.
  • Caching the resulting 404 at the edge extends a brief inconsistency for the whole TTL.
  • Treats a replica miss as uncertain (falls back to the primary) and gives 404s a short TTL.

The reasoning

  1. A miss on a replica means "not here yet" as often as "doesn't exist". Confirm with the primary.
  2. Cache "not found" much more briefly than real answers. Otherwise a moment of lag lasts the whole TTL.

Two reasonable pieces combined into an hour-long bug: Replication that was briefly behind, and a cache that kept the wrong answer. Before caching something, ask whether it's a fact (this code points here) or an observation from a source that might be behind (this replica didn't have it).

Stage 9 of 9 · Defend it

Defend the simple design

Your interviewer pushes back: "This wouldn't scale. I'd expect Cassandra for the links, Kafka for clicks, a dedicated ID-generation service, and separate microservices for creation, redirect and analytics. Why didn't you do that?"

What you need to know first

"Would this scale?" is a question about numbers, so answer with numbers. For each component the interviewer suggests, say two things:

  1. What it would buy you. Every one of those components solves a real problem.
  2. The trigger. Which measurement would tell you it's time to add it.

That shows you know what each component is for, without adding them all up front.

Which is a good trigger for moving the links from Postgres to a partitioned store like Cassandra?

The link table is approaching what one server can store, or writes approach what one primary can take.

It names a measurable limit that the current design will actually hit. At 600 GB a year and about 40 writes a second, that's years away.

What the stage asks

Defend the design with numbers, say what would make you adopt each of those components, and identify the first real bottleneck you expect.

Reference answer

Numbers first. Writes are about 40 a second, reads about 19,000 a second at peak (mostly for a small set of popular links), and storage grows about 600 GB a year. One Postgres primary handles that, and the CDN answers most popular-link reads before they reach it.

When each component would earn its place:

  • Cassandra or another partitioned store: when the link table outgrows one machine's storage or one primary's write capacity, likely years from now. Before that, Postgres partitioning or a managed distributed SQL database is a smaller step.
  • Kafka: once several independent consumers need click events (fraud detection, billing, analytics). For one aggregator, a managed queue or stream is enough.
  • An ID service: only if random codes plus a unique constraint stop working, which with 3.5 trillion possible codes won't happen for a very long time.
  • Microservices: when separate teams own creation, redirect and analytics, or when their scaling or deploy schedules genuinely differ. The redirect path is already isolated by the CDN and the asynchronous click path.

First real bottleneck: probably the analytics write path during big campaigns (handled by batching and the stream) or latency in other regions for less popular links (handled by regional replicas with careful miss handling). Probably not the link table.

What a strong answer covers

  • Uses the numbers: about 40 writes/s, about 19,000 reads/s at peak, about 600 GB a year, all within one database plus caching.
  • Names a concrete trigger for each component (for example storage beyond one machine, write rates, several independent consumers of click events).
  • Identifies a plausible first real bottleneck (storage growth, the analytics write path, or latency in other regions) and the targeted fix.
  • Acknowledges what the proposed components would buy, rather than dismissing them.Supporting

The reasoning

  1. Answer "would it scale?" with your numbers, not with more components.
  2. For each component you leave out, say what would make you add it.

Naming triggers turns a disagreement about taste into one about measurements, which can actually be settled. It also shows the interviewer you know what each component is for.

How you did

Now try it as an interview question

  • “Design a URL shortener like bit.ly.”
  • “Design Pastebin.”
  • “How would you generate unique short IDs across many servers?”
  • “Your cache is serving a deleted item. How do you make deletions take effect immediately?”

The interview mode mixes stages from this and other investigations with concept recall and questions about your own projects.

Back to the last stage