Home › High-Level Design › Design a URL Shortener
D

Design a URL Shortener

We are building the thing behind links like go.ex/x7Qa2b. Paste a long URL in, get a short one back, and every click on the short one bounces the user to the original. It is the warm-up question in almost every system design loop, and that is exactly why it is dangerous: it looks trivial, so the interviewer stops listening to your architecture and starts watching how you handle the small decisions. The gap between a pass and a fail is never "could they draw the boxes." It is whether they said out loud why a 7-character code and not a 6, and what happens when one link goes viral.

High-Level DesignEasy13 min read

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. Read-optimized by design. Roughly 100 clicks per creation, so the redirect path is sacred and the write path can be lazy. Every choice flows from that ratio.
  2. 7-char base62. 62^7 is about 3.5 trillion codes, decades of headroom. A 6-char code exhausts in under two years at this scale, and you show the math, not a guess.
  3. Counter plus base62, scrambled. Block-allocated IDs encoded to base62 for auto codes: collision-free, non-enumerable, no central bottleneck on the hot path.
  4. Layered cache. Local in-process LRU, then Redis, then DB, with request coalescing so a cache miss never stampedes the database.
  5. Hot keys are the real failure. One viral link concentrates on a single cache node, not raw QPS. The local cache gets hotter as the key gets hotter, so hotness is self-defending.

My plan for the 45 minutes. I say it out loud so the interviewer knows I will not burn 20 minutes on the easy parts.

1Clarifying the requirements#

Pin down what you are on the hook for before drawing a single box. Here is roughly how I would open.

Candidate: Are we building a public service like a well-known link shortener, or an internal one for a specific product?

Interviewer: Public. Anyone can create a short link.

Candidate: Do users pick custom aliases, like go.ex/my-launch, or is the code always auto-generated?

Interviewer: Support both. Auto by default, custom alias optional.

Candidate: Do links expire?

Interviewer: Let them set an optional expiry. Default is: lives forever.

Candidate: Do we need click analytics?

Interviewer: Basic click counts are a nice-to-have. Don't let it drive the core design.

Candidate: Last one. What's the rough scale, and how do you want me to weigh read latency against write latency?

Interviewer: Assume it gets big. A new link appearing a few milliseconds late is fine. A slow redirect is not.

What those six lines just told me:

  • The redirect path is sacred; the write path can be lazy. That last answer is the whole thesis.
  • Both auto codes and custom aliases need a shared uniqueness story.
  • Expiry and analytics exist but must not touch the hot lookup.
  • "Assume it gets big" means I am on the hook for caching and the hot-key problem, not CRUD.

Functional requirements

  • Create a short code for a given long URL.
  • Redirect from the short code to the long URL.
  • Optional custom alias chosen by the user.
  • Optional expiry date on a link.
  • Best-effort click counting.

Non-functional requirements

  • Redirects must be fast. I am targeting under 50 ms at the server.
  • Read-heavy. Far more clicks than creations.
  • Highly available for reads. If creation blips for a second, nobody dies. If redirects go down, every link on the internet that points at us is broken.
  • Short codes must be unique and hard to guess in sequence.
  • Scale to billions of stored links over the product's life.
One opinion to state up front

This is a read-optimized system wearing a write API. Keep that in your head the whole time, because every design choice from here flows from it.

From the interviewer's chair

This question looks trivial, so I stop grading the boxes and start grading your judgment. I want to hear you pick the code length from real math, call 301 versus 302 with a reason, and reach for the hot-key problem before I mention it. Draw a clean CRUD app and say nothing opinionated, and I have quietly marked you no-hire on an "easy" question.

2Back-of-the-envelope estimation#

Numbers keep you honest, so pick some and do the math in the open. One number drives everything, and I will circle it when I reach it.

Pick a creation rate.

  • Say we create 100 million new links per day.
  • Aggressive for a new product, but it gives room and the round number makes division easy under pressure.

Write QPS.

100,000,000 / 86,400 sec  ≈  1,160 writes/sec
  • Call it ~1.2K writes/sec average, peak about 3x, so ~3.5K writes/sec.
  • That is nothing. A single well-provisioned database primary handles it without sweating.
  • First hint: writes are not the problem.

Read QPS (the number that matters).

  • A shortener lives and dies on the read side. A 100:1 read-to-write ratio is reasonable for shared links.
1,160 * 100  ≈  116,000 reads/sec average
  • Peak at 3x lands near ~350K reads/sec.
  • You do not serve 350K reads/sec off a cold disk query, full stop.
  • Everything downstream (cache tiers, the service split, the hot-key work) exists to make that 350K survivable.
~350K
Reads per second at peakThe one number that drives the whole design. It is why reads get a cache in front, why read and write are separate services, and why a single viral key can still cook one node even at a 99% hit rate. Remember this one; it comes back in every deep dive.

Storage.

  • Each record holds a short code, the long URL, a creation time, maybe an owner id and expiry.
  • Long URLs vary, but 500 bytes per record all-in is a safe average once you count row overhead and indexes.
100M/day * 500 bytes      = 50 GB/day
50 GB/day * 365           ≈ 18 TB/year
Over 5 years              ≈ 90 TB
  • Tens-of-terabytes land. That is a sharded database, not a single box, but it is not exotic. Nobody gets grilled because 90 TB scared them.

Key space (the place a careful candidate pulls ahead early).

  • Base62 is a to z, A to Z, 0 to 9.
62^6  = ~56.8 billion  codes
62^7  = ~3.5 trillion  codes
  • At 100M links/day, a 6-char code exhausts in under two years (56.8 billion / 100M per day is roughly 568 days).
  • So I go with 7 characters: 3.5 trillion codes, decades of headroom.
  • I say that derivation out loud. Picking code length by hand-waving is the classic junior tell; showing the exhaustion date is the easiest "this person has done the math" signal in the whole interview.

Bandwidth for reads.

  • A redirect response is tiny, maybe 500 bytes of headers.
350,000 reads/sec * 500 bytes  ≈ 175 MB/sec outbound
  • Very manageable. The bottleneck is request count, not bytes, which again points at caching and connection handling, not fat pipes.

3Core APIs#

Two endpoints do the real work. Keep the surface small on purpose; a wide API is a smell on a system this focused.

POST /api/v1/links
Content-Type: application/json

{
  "long_url": "https://example.com/some/very/long/path?utm=spring",
  "custom_alias": "spring-sale",
  "expires_at": "2027-01-01T00:00:00Z"
}

Response:

{
  "short_code": "spring-sale",
  "short_url": "https://go.ex/spring-sale",
  "long_url": "https://example.com/some/very/long/path?utm=spring",
  "expires_at": "2027-01-01T00:00:00Z"
}

Two creation decisions I would defend if asked:

  • Authenticated or rate-limited. Registered users authenticate; anonymous ones are rate-limited. An open create endpoint is a spam magnet and we do not want our domain reputation torched by phishing links.
  • Idempotent per user plus long-URL plus alias. A retried POST does not mint a second code.

And the redirect, which is the whole point:

GET /{short_code}
HTTP/1.1 301 Moved Permanently
Location: https://example.com/some/very/long/path?utm=spring

301 versus 302, worth defending in the room.

  • A 301 (permanent) lets browsers and proxies cache the redirect, which takes load off us for free.
  • But a cached 301 means the browser stops asking us, so your click analytics go dark.
  • I default to 302 (temporary) so every click hits our servers.
  • I only use 301 for links I am certain will never change and never need counting.
  • State the trade-off out loud. "It depends on whether we need analytics" is the exact answer the interviewer is fishing for.

4High-level design#

The point of this section

The boxes are table stakes. What earns points is splitting read and write into separate services and saying why: they scale on opposite curves, and that isolation lets the key allocator fall over without a single redirect noticing. Draw the spine fast, then defend the split.

The architecture has two clear paths that share a database and almost nothing else.

  • A write path that mints codes.
  • A read path that must be fast enough to hide.
flowchart LR U[Client] --> LB[Load Balancer] LB --> W[Write Service] LB --> R[Read Service] W --> KG[Key Generator] W --> DB[(Link DB)] R --> CA[(Redis Cache)] R --> DB CA -.miss.-> DB
The whole system at a glance. The dotted miss line from cache to DB is where latency goes to die if you get it wrong, so section 6 spends real time on it. Notice read and write never share a code path.

Write path.

  • Take a long URL.
  • Get a unique code from the key generator.
  • Write the row to the Link DB.
  • Return the short URL.

Read path.

  • Take a code.
  • Check Redis first.
  • On a miss, read the database and warm the cache.

Why split read and write into separate services.

  • They scale on different curves: reads outnumber writes 100 to 1, so I add read replicas and cache nodes without touching the write side.
  • A flood of link creation (someone scripting a million links) can never starve the redirect path.
  • Different code, different deploy cadence, different failure domain.
  • That isolation pays off directly in the bottlenecks section: the key allocator can fall over and reads never notice.

5Data model#

The main table is almost embarrassingly simple, which is a feature, not a thing to apologize for.

Field Type Notes
short_code VARCHAR(10) Primary key. Base62 code or custom alias.
long_url TEXT The destination.
owner_id BIGINT Nullable. Who created it, null for anonymous.
created_at TIMESTAMP When minted.
expires_at TIMESTAMP Nullable. Null means never expires.
click_count BIGINT Best-effort counter, updated async.
  • I shard this table by short_code. The lookup that matters, "given a code, find the URL," is a single-key point query.
  • Hashing the code to a shard gives even spread and keeps every redirect to one shard hop.
  • No cross-shard scatter on the hot path, which is the whole game.

Analytics live in their own table, never mixed into the hot lookup row:

Field Type Notes
event_id BIGINT Primary key.
short_code VARCHAR Which link was clicked.
clicked_at TIMESTAMP Event time.
country VARCHAR Geo from IP, nullable.
referrer VARCHAR Where the click came from, nullable.
  • The click_count on the main row is a denormalized rollup updated asynchronously off this event stream, so the redirect itself never does a write.
  • The redirect fires an event onto a queue and returns immediately; a consumer batches the increments.
  • What is NOT on the redirect path: no join, no synchronous write, no second table read. A redirect is one key lookup and nothing else. Guard that property with your life.

6Deep dives#

6.1 Generating unique short codes#

This is the actual hard part of an "easy" question. Candidates split into three camps, and walking all three beats picking one blindly.

  • Option A: hash the URL. Run MD5 or SHA over the long URL, take the first 7 base62 chars.
  • Clean, but two different long URLs can collide on those first 7 chars.
  • Worse, the same long URL always produces the same code, which breaks the moment two users want separate links (and separate analytics) for one destination.
  • I would only reach for it if dedup were an explicit requirement, which it is not here.

  • Option B: random code with collision check. Generate 7 random base62 characters, try to insert, retry on a primary-key clash.

  • With a 3.5-trillion key space and tens of billions of rows, collision rate is tiny, so retries almost never happen.
  • Simple and correct. The only cost is one extra round trip on the rare collision.
  • Needs no central coordination at all, which is a real operational advantage.

  • Option C: counter plus base62 encoding. Keep a global monotonic counter and encode each number into base62 (value 125 becomes cb).

  • Guaranteed unique, zero collision checks.
  • The catch: a single counter is a bottleneck and a single point of failure.
  • The fix is a range-allocation service (a ticket or ID server). Each write node asks for a block of a thousand IDs, hands them out locally, and talks to the central allocator once per block.
  • One network round trip buys a thousand codes, so the allocator sees roughly 3.5 requests per second at peak instead of 3,500.
sequenceDiagram participant W as Write Service participant K as Key Allocator participant DB as Link DB W->>K: request ID block K-->>W: range 400000 to 400999 W->>W: pick next id encode base62 W->>DB: insert code and long url DB-->>W: ok W-->>W: return short url
Block allocation in action. The write node grabs a thousand IDs in one round trip, then mints codes locally. The central allocator sees one request per thousand links, which is why it almost never becomes the bottleneck.
Pause before you read on: you use a plain counter and encode it straight to base62. Beyond the single-point-of-failure worry, what did you just leak?
Reveal the answer
Sequential codes are enumerable. Anyone can walk 000001, 000002, 000003 and dump every link in the system, which is a privacy and competitive-intel leak. The fix is to scramble the counter output with a reversible permutation (a Feistel cipher, or a shuffled base62 alphabet) so consecutive IDs do not map to adjacent-looking codes. Reversible and deterministic means uniqueness is untouched. Raising this unprompted is a staff-level signal.

Where I usually land.

  • Counter-allocated IDs encoded to base62 for auto codes, because it is collision-free and spreads nicely across shards, with the random approach as a fallback.
  • Scramble the counter output (a reversible bit permutation like a Feistel cipher, or a shuffled base62 alphabet) so consecutive IDs do not map to adjacent-looking codes.
  • The scramble is reversible and deterministic, so uniqueness is untouched.

Custom aliases ride a different track.

  • The user hands me the code, so I try to insert it and return a clean "already taken" error on a primary-key conflict.
  • The subtle bit is keeping the auto-generated space and the custom-alias space from stomping on each other. Two clean options:
  • Reserve custom aliases to a separate namespace (a prefix, or a minimum length the auto generator never produces).
  • Or let both live in the same table and rely on the unique primary key to arbitrate, first writer wins.
  • Block aliases that collide with reserved routes like api or login. go.ex/api resolving to someone's link is a real foot-gun.

6.2 Making reads fast: the cache#

At 350K reads/sec, the database is not your serving layer, the cache is. I put Redis in front and lean on one fact: link popularity follows a power law.

  • A small slice of links gets the overwhelming majority of clicks.
  • One celebrity tweet link might be 5% of all your traffic by itself.
  • Billions of links in the long tail get clicked twice and then never again.

So I cache aggressively with an LRU policy.

  • The hot links stay resident, the long tail falls out, and that is fine because the long tail is rare by definition.
  • Expect north of a 90% hit rate once warm.
  • At 90%, that is roughly 35K reads/sec reaching the DB instead of 350K, and sharded across the DB that is comfortable.
flowchart TD A[Read request for code] --> B{In Redis} B -- yes --> C[Return long url] B -- no --> D[Query Link DB] D --> E[Write code to Redis] E --> C
The read-through cache. A hit returns in one hop; a miss costs one DB query and warms the cache for next time. The 90% hit rate is what turns 350K reads/sec into a DB load the shards barely feel.

Entries are cheap.

  • A code maps to a URL, a few hundred bytes.
  • Holding the hottest few hundred million links in memory across a Redis cluster is well within reach of commodity hardware, call it a few hundred GB of RAM spread across the cluster.
  • Read-through pattern as drawn, with a TTL so a URL edited at the source eventually reflects (with the celebrity-link caveat covered next).

6.3 The hot-key problem#

This is where strong candidates separate from the rest, and the deep dive I would steer toward if the interviewer lets me.

  • Caching solves average load, but a single viral link concentrates traffic on one cache key on one Redis node.
  • If a link suddenly pulls 50K reads/sec, that is 50K requests hammering a single shard.
  • You have a hot key even though your cache hit rate looks beautiful at 99%. Your dashboard says healthy; one Redis node is on fire.

Three defenses, layered on purpose.

  1. Local in-process cache. Each read service node keeps a tiny LRU of the few thousand hottest codes in its own memory, with a short TTL (a few seconds).
  2. A viral link gets answered from RAM on the app server itself and never touches Redis.
  3. Spread 50K reads/sec across even 20 read nodes and each serves 2.5K/sec from its own heap.
  4. This is the single biggest win for hot keys, and it costs almost nothing.

  5. Key replication. Store the hottest keys on multiple Redis nodes with a suffix (code#1, code#2), and have clients pick one at random.

  6. This spreads one logical key across several physical nodes, so no single Redis instance eats the whole viral load.

  7. Lean on 301s for the truly viral ones. For a link that is clearly never changing, a cacheable 301 lets the browser and CDN absorb repeat clicks entirely.

  8. You trade analytics precision for near-infinite scale on that one link.
  9. For a link already doing 50K/sec, the marginal click count matters less than staying up.
flowchart LR U[Client] --> N[Read Node] N --> L[(Local LRU)] L -.miss.-> RC[(Redis Cluster)] RC -.miss.-> DB[(Link DB)]
The three cache layers. Each one absorbs what the layer below cannot, and the hottest keys never fall past the first. Notice the hotter a key gets, the more it stays resident in every local LRU at once, so hotness defends itself.

The hotter a key gets, the better the local cache works. Hotness is self-defending.

Red flag that fails you here

Showing a 99% cache hit rate and calling the read path solved, then freezing when I say "one link just went viral." A high hit rate hides a single-node fire. Reach for the local in-process cache on your own and explain why it gets stronger as the key gets hotter. Candidates who never see past the aggregate hit rate have not run a cache under a real spike.

7Bottlenecks and trade-offs#

What breaks first at scale: redirect tail latency from cache-miss stampedes.

  • When a hot key expires from cache, a thousand concurrent requests all miss at once and all hit the database for the same row.
  • That is a cache stampede, and it can take down a DB that was fine a second earlier.
  • Defend with a short lock or a request-coalescing layer so only one request refills the cache while the rest wait a few milliseconds for the result.
  • One miss, one DB query, everyone else piggybacks.

The key allocator as a single point of failure.

  • If every write needs the central allocator and it goes down, creation stops.
  • Block allocation already softens this: each node has a thousand IDs buffered, so it keeps minting for a while on its own.
  • Run the allocator itself as a replicated service with a consensus-backed counter.
  • Worst case, if it is fully down, writes degrade to the random-code-with-retry path, which needs no coordination.
  • Reads are completely unaffected, which is exactly the isolation I wanted from splitting the services back in section 4.

Consistency on creation.

  • There is a tiny window where a code is minted but not yet replicated to every read replica, so a click arriving a few milliseconds after creation might miss.
  • I accept this. The person who just made a link and immediately clicks it is a rounding error.
  • Read-your-writes is handled by routing the creator's first read to the primary.
  • The interviewer told me a few ms of staleness is fine, so I spend my consistency budget exactly where they said I could.

Database choice.

  • I reached for a sharded relational store, but a key-value store (the lookup is literally key to value) is just as defensible, and I say so rather than pretend there is one right answer.
  • The access pattern is pure point lookups, so a KV or wide-column store shards and scales this cleanly.
  • The relational engine earns its keep only if custom aliases, owner accounts, and expiry queries grow into real relational features with foreign keys and reporting.
  • State your pick, state the alternative, move on.

Expiry cleanup.

  • The redirect path checks expires_at and returns a 404 or "link expired" page if it is past, so an expired link is never followed even before cleanup runs.
  • A background janitor sweeps expired rows in batches during off-peak hours so cleanup never competes with live traffic.
  • Drop expired keys from the cache on read so stale entries self-heal.

8Summary#

The decisions I would want an interviewer to remember:

  • Read-optimized by design. 100:1 read-to-write, so I split read and write services and let them scale independently.
  • 7-character base62 codes. Chosen from real math on the key space, not guessed: 3.5 trillion codes and decades of headroom, versus a 6-char code exhausting in under two years.
  • Counter-allocated IDs with block allocation, encoded and scrambled into base62 for auto codes. Collision-free, non-enumerable, no central bottleneck on the hot path.
  • Layered caching (local LRU, then Redis, then DB) with request coalescing to kill stampedes.
  • Explicit hot-key handling, because viral links are the real failure mode, not raw QPS.
  • 302 by default so analytics work, 301 only when a link is certain and viral.
Follow-ups the interviewer will ask (and your answers)
"Why 7 characters and not 6 or 8?"
Base62 at 6 chars is about 56.8 billion codes, which at 100M links/day exhausts in roughly 568 days, under two years. Seven chars is about 3.5 trillion, decades of headroom at the same rate. Eight would waste a character with no payoff at this scale. I pick the length from the exhaustion date, not from a guess.
"A plain counter encodes to sequential codes. Isn't that a leak?"
Yes. Sequential codes let anyone enumerate every link by walking 000001, 000002, and so on. I scramble the counter output with a reversible permutation (a Feistel cipher or a shuffled base62 alphabet) so consecutive IDs produce non-adjacent codes. It is reversible and deterministic, so uniqueness is untouched.
"Your cache hit rate is 99% but one Redis node is overloaded. How?"
A single viral link is one hot key on one node, so the aggregate hit rate looks great while that shard burns. I answer it from a per-node local in-process LRU with a short TTL, so a viral link is served from app-server RAM and barely touches Redis. I can also replicate the key across nodes with a suffix and pick one at random.
"A hot key expires and a thousand requests miss at once. Now what?"
That is a cache stampede. I use request coalescing or a short lock so only one request refills the cache while the rest wait a few milliseconds and piggyback on its result. One miss, one DB query, no thundering herd on the database.
"301 or 302 for the redirect?"
302 by default, so every click hits our servers and analytics stay accurate. A 301 is cacheable by browsers and proxies, which offloads us but makes click counts go dark. I only use 301 for links I am certain will never change and never need counting, like a truly viral link where staying up beats a precise count.

What you can now explain in the room

  • Why a shortener is a read-optimized system, and how the 100:1 ratio justifies splitting read and write services.
  • How to pick the code length from the key-space math, and why 6 chars exhausts in under two years at 100M/day.
  • The three code-generation strategies and why counter-plus-base62 with a scramble wins for auto codes.
  • Why sequential IDs leak, and how a reversible permutation fixes it without touching uniqueness.
  • How layered caching plus request coalescing turns 350K reads/sec into a load the DB barely feels.
  • Why a 99% hit rate can still cook one node, and how a local in-process cache makes hotness self-defending.

For how this question shows up in specific loops, see our company-specific guides on Google, Amazon, and Meta system design rounds.

Get a prep plan built around your Design a URL Shortener interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design a URL Shortener prep plan is on the way.