The URL Shortener, End to End
Everybody's first system design question. It looks trivial — take a long link, hand back a short one — but it quietly teaches uniqueness at scale, caching, and the read/write asymmetry that shapes half the systems you'll ever design.
OrientationThe framework the whole answer hangs on
Before touching the whiteboard, Evan lays out the sequence he follows for every system design interview. Knowing the steps is what lets you build up an unfamiliar system linearly instead of panicking.
High-level design satisfies the functional requirements with the simplest thing that works. Deep dives then walk the non-functional requirements one at a time, evolving that simple design until it also meets scale, latency, uniqueness, and availability. Features first, qualities second.
“A common mistake I see candidates make is that they go too deep on any given part of the design too early, and then all of a sudden time runs out and they didn't even get to satisfy all the functional requirements.” Stay high level, get the base working, then come back and go deep.
Evan King, video walk-throughStep 1Understanding the problem
A service that converts long URLs into shorter, manageable links, then redirects anyone who visits the short link back to the original. Bitly also offers analytics on those links (out of scope here).
Functional requirements — the top 3–4 features
Functional requirements are the features the system must have. Zero in on the few that matter and don't get distracted by bells and whistles. Phrase them as users can… statements.
- Users can create a short URL from a long URL.
- Optionally specify a custom alias (e.g. short.ly/evan).
- Optionally specify an expiration date.
- Users can access the original URL by visiting the short URL.
Below the line (out of scope): user auth & accounts, and analytics on clicks. They add complexity without being core.
Non-functional requirements — the qualities
These are the “-ilities”: latency, scalability, durability, availability, consistency. Go through them one by one and keep only the ones that are uniquely interesting for this system, phrased as the system should… statements and quantified where possible.
- Uniqueness of short codes — each code maps to exactly one long URL, so nobody is redirected somewhere unexpected.
- Low-latency redirects — under ~200 ms.
- High availability > consistency — reliable ~99.99% of the time; eventual consistency is fine.
- Scale — support 1B shortened URLs and 100M daily active users.
200 ms is roughly the threshold humans perceive as “real time” — the snap of a finger. Anything faster and a user can't tell the difference, so it's a sensible lower bound for the redirect.
Evan King, videoReads (redirects) vastly outnumber writes (new links) — think roughly 1000 clicks per 1 URL created. This single fact drives caching strategy, database choice, and the whole architecture. Say it out loud early and structure your design around it.
Deciding availability vs consistency (CAP)
In distributed systems you must accept partition tolerance, so CAP really becomes: do I need strong consistency, or high availability? The test Evan uses: does every read have to see the latest write, or the system breaks?
Systems that need read-after-write consistency: a ticket booking system (two people can't buy the last seat to the same concert) or a banking / trading app (a trade must be visible everywhere before the next buy/sell). A URL shortener isn't like that. There's a natural delay between creating a link and someone clicking it, and if a click in the first few seconds fails, “who cares — show a little error, try again in a minute. The world wouldn't end.” So we choose availability + eventual consistency.
Evan King, videoMany guides tell you to do back-of-the-envelope estimations here. Evan argues it's usually useless upfront: candidates compute storage/latency, then conclude “so it's a big distributed system” — which we already knew from 100M DAU. Do math only when the result will directly change a decision, during the high-level design or deep dives.
On the whiteboard, this is all you write:
# Functional
- create short url from original url (+ optional custom alias, expiration)
- access original url by visiting the short url
# Non-functional
- ensure uniqueness of short codes
- low-latency redirects (< 200ms)
- availability >> consistency (eventual consistency ok)
- scale to 1B urls and 100M DAU
Steps 2–3Core entities & the API
Start with a broad list of entities — the “tables” you'll persist and exchange — without agonizing over columns yet. Tell your interviewer you'll flesh out the full data model during the high-level design.
- Original URL — the long link the user wants to shorten.
- Short URL — the short link they receive and share.
- User — who created it.
✦TipThis system is simple enough that you could write the full data model now. For more complex systems it's usually too early — you haven't thought through the API yet. Announcing the plan (“I'll list entities now, columns later”) keeps you and the interviewer aligned.
The API — usually 1:1 with the features
Walk your functional requirements one by one and define the endpoint each needs. For REST, pick the right verb: POST create, GET read, PUT/PATCH update, DELETE delete.
// 1) Shorten a URL — creating a new mapping, so POST
POST /urls
{
"long_url": "https://www.example.com/some/very/long/url",
"custom_alias": "optional_custom_alias",
"expiration_date": "optional_expiration_date"
}
-> { "short_url": "http://short.ly/abc123" }
// 2) Redirect — reading an existing mapping, so GET
GET /{short_code}
-> HTTP 302 redirect to the original long URL
“As an interviewer I don't care about all of this — it's silly semantics that are easy to change.” But be aware some interviewers are “RESTful API zealots,” so get your verbs and plural nouns roughly right and move on.
Evan King, videoStep 4High-level design
Go endpoint by endpoint and draw the smallest system that satisfies it. Ignore scale, latency, and uniqueness for now — that's what deep dives are for.
3.1 · Creating a short URL
(magic black box)
The flow: the server validates the long URL format, generates a short code (deep dive below), and — if a custom alias was given — checks it doesn't already exist before using it. It stores the row and returns the short URL. For the redirect path we'll make the short code the primary key.
Most shorteners allow multiple short codes for the same long URL. Different users may want their own expiration date, independent analytics, or a custom alias. Deduplicating (returning an existing code) trades those features for storage savings — usually not worth it.
A custom alias could collide with a future counter-generated code. Prevent it by prefixing generated codes with a character aliases can't use, or storing the two in separate namespaces.
3.2 · Redirecting to the original URL
Crucially, short URLs live on a domain you own (short.ly), so every visit to short.ly/abc123 hits your server. It looks up the code, checks expiration (return 410 Gone if expired), and sends an HTTP redirect the browser follows automatically — the user never notices.
| Redirect | Behaviour | Trade-off for a shortener |
|---|---|---|
| 301 Moved Permanently | Browsers & DNS cache it; future clicks may skip your server entirely. | Cheaper / faster, but you lose control & visibility. |
| 302 Found | Temporary; not cached, so every click comes back through you. | Preferred — lets you update/expire links & track clicks. |
HTTP/1.1 302 Found
Location: https://www.original-long-url.com
Even though analytics is out of scope, he still prefers 302. With 301 the request never reaches your server, so you lose visibility: “if you saw things drop off a cliff, you'd know something broke.” 302 keeps that monitoring signal even when you aren't showing users analytics. 301 is the right call only if you're optimizing hard for compute cost and don't need that signal.
Evan King, videoIf you support expiration, a cached 301 can be redirected indefinitely — you can't stop issuing it at expires_at, and proxies may ignore your Cache-Control header. Reader eigenben notes this alone is a reason to use 302 when expiration matters. Others add: 301 also means you permanently lose analytics for anyone who already cached the redirect, so switching to 302 later doesn't recover them.
Run a periodic background job to delete expired rows (or just leave them with their expiration date). More importantly, set the cache TTL ≤ the URL's expiration so stale entries evict themselves automatically.
Deep dive 1Ensuring short codes are unique
Now we open the black box. Three competing constraints, and it's hard to win all three at once:
- Unique — no collisions, ever.
- Short — ~5–7 characters (it's a shortener).
- Efficient — cheap and fast to generate.
Option 1 — Long URL prefix ✗
Take the first N characters of the input URL. Immediately fails uniqueness. Every URL under twitter.com/… or facebook.com/… shares a prefix, so you get a one-to-many mapping and can't know which long URL a short code meant.
Option 2 — Hash function (a great solution)
Feed the (canonicalized) long URL to a hash like SHA-256, base62-encode the output, and slice the first ~6–8 characters. Hashes are deterministic with a strong “waterfall” effect — flip one input bit and the whole output changes — so the sliced result behaves just like a random number.
input_url = "https://www.example.com/some/very/long/url"
# canonicalize first: lowercase host, strip default ports, normalize slash
canonical_url = canonicalize(input_url)
hash_code = hash_function(canonical_url)
short_code_enc = base62_encode(hash_code)
short_code = short_code_enc[:8] # 8 chars -> 62^8 ≈ 218 trillion
Base62 encodes numbers using 62 symbols: 0-9, then a-z, then A-Z. So 10 becomes a, 11 becomes b, and each character carries 62 options instead of 10. It's base62 not base64 because we drop + and / — the slash is a URL path separator and the plus can mean “space” in query strings.
Random-ish codes still collide, and sooner than intuition says. With a 6-char space (62⁶ ≈ 56 billion) and 1 billion codes generated, Evan computed roughly 880,000 collisions. (Same phenomenon as: 23 people in a room → >50% chance two share a birthday.) The general rule: with a code space of size |S| and n codes in use, the next code collides with probability n / |S|.
Handling collisions: put a UNIQUE constraint on the short-code column, and on insert failure retry (bounded, e.g. 3–5 attempts) with a random salt added to the hash. So the write path becomes generate → check/insert → retry on conflict. An extra read, but not a big deal.
Lowering collision probability means longer codes (more entropy) — which defeats the point of a shortener. Collision detection also adds DB lookups and latency. You can't fully optimize unique + short + efficient simultaneously.
Option 3 — Unique counter + base62 (a great solution)
Why introduce randomness at all? Just hand the first URL code 1, the next 2, and so on — then base62-encode the counter to keep it compact. Collisions become impossible because the value always increases.
Redis is single-threaded (one command at a time → no race conditions) and its INCR command atomically increments and returns the new value. Two simultaneous calls always get different numbers — one gets 1000, the other 1001. That guarantee is exactly what a distributed counter needs.
1,000,000,000 in base62 is '15ftgG' — just 6 characters. Even a billion URLs stay compact. You only reach 7 characters at 62⁶ ≈ 56 billion URLs, which then buys you 62⁷ ≈ 3.5 trillion. This is a great example of doing math because it settles a decision.
Anyone can count and base62-encode. Competitors could learn how many URLs you've shortened, and someone could scrape every long URL by walking the codes. Mitigations: a warning in the product (“don't shorten private URLs”), rate limiting, or a bijective function.
A bijective function is a reversible 1-to-1 mapping. Feed it your sequential counter and it returns a Bitly-looking base62 string via obfuscation, so competitors can't guess the next code — without any collision risk. Evan points to the Sqids library. “You don't need to know this in an interview, but if you brought it out they'd probably be impressed.” Stefan Mai adds in the comments: a fixed-width block cipher achieves the same with no extraneous collisions.
Evan King & Stefan MaiOptions 2 (hash) and 3 (counter) both pass — just call out the collision check for hashing. Evan personally likes the counter because it needs no extra read. The differences are small; pick the one you can defend. Snowflake IDs, someone notes, are essentially the same idea as the hash approach (they just make codes ~11 chars instead of ~7).
From the comments: is generating the same short code for the same long URL desirable?
Generally no. Each short URL is a separate entity that can carry its own expiration date and analytics, and forcing dedup would reveal that a link was already created — a privacy leak. Two different users shortening the same long URL getting different codes is fine, even expected.
Deep dive 2Making redirects fast
The expensive step in a redirect is the database lookup. Without help, finding a code among a billion rows means a full table scan — checking every row. We fix that in layers.
Layer 1 — Add an index
Like a book's table of contents: a separate, sorted structure that points to where each row lives, so the DB can jump straight to a match instead of scanning. Make the short code the primary key — that gives you a B-tree index and uniqueness enforcement in one move.
“where is abc123?”
The index still means going to disk. And the volume is the real problem: 100M DAU × ~5 redirects/day ≈ 500M/day ≈ 5,787 rps evenly spread — but multiply ~100× for peak spikes and you're near ~600k reads/sec. One DB instance will struggle, risking timeouts that also hurt writes.
Layer 2 — In-memory cache (Redis / Memcached)
Put a cache between server and database. Check it first; on a hit, return the long URL straight from memory. The speed gap is the whole point:
| Medium | Access time | Throughput |
|---|---|---|
| Memory | ~100 ns (0.0001 ms) | millions of reads/sec |
| SSD | ~0.1 ms | ~100k IOPS |
| HDD | ~10 ms | ~100–200 IOPS |
Memory is ~1,000× faster than SSD and ~100,000× faster than HDD.
Read-through: on a cache miss, fetch from the DB, populate the cache, then return — so the next call is a fast O(1) hit. LRU (Least Recently Used): when memory fills, evict whatever hasn't been touched lately. Perfect here: some links are red-hot, but most go cold after weeks. Key = short code, value = long URL.
Invalidation is tricky on updates/deletes (mostly fine here since URLs rarely change). The cache needs to warm up — early requests still hit the DB. And you must choose size + eviction policy deliberately. Discuss the invalidation strategy with your interviewer.
Layer 3 — CDN & edge computing
Serve the short-URL domain through a CDN with points of presence worldwide. Cache mappings at the edge, and even run redirect logic there (Cloudflare Workers, Lambda@Edge) so popular codes redirect close to the user and never reach your origin — a request from China resolves in China, not California.
He'd actually stop at the Redis cache. Pushing to a CDN reintroduces the exact 301-vs-302 tension: if redirects resolve at the edge and never hit your server, you lose visibility and analytics. Edge also adds cost, config, cross-node invalidation, and harder debugging. “You're trading cost and complexity for performance — whether it's worth it depends on price sensitivity, UX needs, and traffic.”
Evan King, videoDeep dive 3Scaling to 1B URLs & 100M DAU
The cache already handled most read scaling. Now walk the system left to right and check each component for bottlenecks.
The database — smaller than you'd think
Each row: short code ~8 B, long URL ~100 B, createdAt ~8 B, optional alias ~100 B, expiration ~8 B ≈ ~200 B. Round up to 500 B for metadata.
500 bytes × 1,000,000,000 rows = 500 GB
That fits comfortably on a single modern SSD. Writes are tiny too — maybe ~100k new URLs/day ≈ ~1 write/sec — so almost any database works (Postgres, MySQL, DynamoDB). Pick what you know; default to Postgres. Only shard (e.g. by short code, modulo N) if you ever hit a hardware limit.
Splitting read and write services
Sizing the primary server: 100M DAU × ~1 redirect ÷ ~100k sec/day ≈ 1,000 rps even, ~10–100k rps at peak. A single mid-size instance handles ~1,000 concurrent requests, so we must scale out. Since reads dwarf writes, split into two independently scaled services behind an API Gateway (which routes by endpoint) and a load balancer.
10⁸ users ÷ 10⁵ sec = 10³ = 1,000 rps — subtract the exponents and the arithmetic is trivial under pressure. Also: modern cloud autoscaling handles the up/down for you (add a box at 75% CPU/memory, remove under 20%), so you rarely control it manually.
Evan King, videoEvan flags it honestly: there's so little code on each box that maintaining two services can be overkill. Keeping them together and scaling as one is a perfectly valid alternative — it's a trade-off to discuss, not a rule.
If each write instance keeps its own counter, they all start at 0 and hand out duplicate codes. The counter can't live in instance memory anymore — you need a single source of truth.
The global counter
Pull the counter into a centralized Redis instance that every write service asks for the next value via atomic INCR. To avoid a network hop per write, use counter batching:
- Each write instance requests a batch (e.g. 1,000 values) from Redis.
- Redis atomically increments by 1,000 and returns the start of the range.
- The instance uses those 1,000 values locally — no Redis call per URL.
- When the batch runs out, it requests the next one.
If an instance dies mid-batch, the unused values are gone forever — and that's OK. We only need uniqueness, not continuity, and we have trillions of codes to spare. The DB's UNIQUE constraint on the code is the ultimate safety net. For multi-region, give each region a disjoint counter range (A: 0–1B, B: 1B–2B) to avoid cross-region coordination.
High availability
Autoscaling + horizontal scaling already gets you most of the way. Component by component:
- Cache down? No problem — it's read-through, so it reloads from the DB. Slower briefly, no data loss.
- Global counter down? That is a problem. Run Redis in HA mode (Sentinel / Cluster with automatic failover) and periodically snapshot the count to disk.
- Database down? Keep a replica to fail over to, and snapshot to cheap blob storage (S3) hourly so you can rebuild.
Readers pushed hard here: a Redis replica can hold stale data if the master dies before replicating, so on a duplicate-key error you should immediately fetch a fresh batch and retry. Several argue a Postgres SEQUENCE or Snowflake IDs would avoid the extra Redis dependency on the critical path — though a plain Postgres sequence isn't horizontally scalable and adds ~1–2 ms. There's no single “right” answer; it's a genuine trade-off conversation.
Step 4, evolvedThe final design
+ replica + S3 snapshots
A read-heavy key→value store. Guarantee uniqueness with a base62-encoded counter (or checked hash). Guarantee speed by serving reads from an LRU cache backed by an indexed DB. Guarantee scale by splitting read/write services, centralizing the counter with batching, and keeping HA replicas — all justified by the ~1000:1 read/write ratio.
CalibrationWhat's expected at each level
| Level | What the interviewer looks for |
|---|---|
| Mid-level | A working high-level design: submit → generate code → store → redirect. Recognize codes must be unique and propose one approach (hash or counter). Understand why 302. With prompting, realize a cache helps reads. |
| Senior | Drive the conversation; proactively name the key challenges (unique codes at scale, fast redirects, horizontal scaling). Articulate hash-vs-counter trade-offs unprompted. Detail caching incl. invalidation for expired URLs. Justify a DB choice. Split read/write services and scale the counter. |
| Staff+ | See past the textbook. Structure for read-heaviness from the start. Proactively discuss multi-region, counter-range allocation, and Redis failover. Address predictable-code security. Show product thinking: alias collisions, expiration cleanup, how the system evolves — i.e. operating it at scale, not just solving it. |
ReconstructActive recall
Answer before expanding. These test whether you can rebuild the reasoning, not recite facts.
Why does this system choose availability over consistency? Give the test you'd apply.
Ask: does every read have to see the latest write or the system breaks? For a shortener, no — there's a natural delay before a new link is clicked, and a rare early failure can just show “try again in a minute.” Contrast with ticket booking or banking, where stale reads cause double-bookings or bad trades. So we pick high availability + eventual consistency.
Explain, from scratch, why a randomly generated 6-char code isn't as safe from collisions as it looks.
The Birthday Paradox. Even with 62⁶ ≈ 56B possible codes, generating 1B of them yields ~880k collisions, because collision probability grows with the number of pairs, not just the ratio to the space. Formally the next code collides with probability n/|S|. So you still need a UNIQUE constraint + retry, or a scheme that can't collide.
What breaks when you horizontally scale the write service, and how do you fix it?
An in-memory counter per instance hands out duplicate codes (each starts at 0). Fix: move to a single global counter in Redis using atomic INCR. To cut network hops, batch (grab 1,000 values at a time); losing unused values on crash is fine since we only need uniqueness. For multi-region, assign disjoint ranges.
Walk the redirect path and name every latency optimization in order.
Client → API Gateway → Read Service → Redis cache (hit = O(1) from memory, ~100 ns) → on miss, DB lookup via B-tree/primary-key index (O(log n), avoids full scan) → populate cache → return 302. Optional further layer: CDN/edge cache so popular codes never reach the origin. Layers: index → cache → (edge).
When would 301 beat 302, and what's the hidden danger of 301 with expiration?
301 (permanent, cacheable) wins when you're optimizing for compute cost and don't need per-click visibility — browsers/CDNs handle it and it may never hit your server. Danger: a cached 301 can redirect indefinitely, so you can't reliably stop it at expires_at (proxies may ignore Cache-Control), and you permanently lose analytics for anyone who already cached it. Prefer 302 when expiration or monitoring matter.
How do you argue that a single Postgres instance is enough for storage here?
Row ≈ 500 B; 500 B × 1B rows = ~500 GB, well within one SSD. Writes are ~1/sec. Reads are offloaded to the cache. So no sharding needed — and this is math done because it settles the decision, exactly when Evan says to do it. Shard by short code only if a hardware limit is ever hit.
What's a bijective function doing in this design, and what problem does it solve?
A reversible 1-to-1 map (e.g. Sqids, or a fixed-width block cipher) applied to the sequential counter before/instead of plain base62. It produces a Bitly-looking code that hides the underlying sequence, so competitors can't guess the next code or scrape URLs — while preserving guaranteed uniqueness (no added collisions).
60-second refreshQuick revision
Must remember
- Read/write ratio ≈ 1000:1 drives everything.
- Availability > consistency; eventual is fine.
- Counter + base62 = zero collisions; hash = check & retry.
- Cache (LRU, read-through) → index → optional CDN.
Rules & formulas
- Collision prob ≈ n / |S|.
- 1B → base62 = 6 chars; 62⁶ ≈ 56B.
- 10⁸ ÷ 10⁵ = 10³ = 1,000 rps (÷ exponents).
- Redis INCR is atomic & single-threaded.
Common mistakes
- Going too deep too early → run out of time.
- Doing back-of-envelope math upfront for no decision.
- Per-instance counter after scaling out.
- 301 + expiration → indefinite cached redirect.
Key distinctions
- 301 caches (skips you) vs 302 always returns.
- Hash = dedup/deterministic; counter = sequential/predictable.
- Cache down = fine; counter down = problem.
- Vertical (bigger box) vs horizontal (more boxes).