If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- 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.
- 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.
- Counter plus base62, scrambled. Block-allocated IDs encoded to base62 for auto codes: collision-free, non-enumerable, no central bottleneck on the hot path.
- Layered cache. Local in-process LRU, then Redis, then DB, with request coalescing so a cache miss never stampedes the database.
- 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.
- 5 minutes pinning requirements.
- 5 on the math.
- 5 on APIs and data model.
- The rest on the two deep dives that actually earn the hire: code generation and the hot-key problem.
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.
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.
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.
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 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.
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_counton 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.
Reveal the answer
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
apiorlogin.go.ex/apiresolving 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.
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.
- 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).
- A viral link gets answered from RAM on the app server itself and never touches Redis.
- Spread 50K reads/sec across even 20 read nodes and each serves 2.5K/sec from its own heap.
-
This is the single biggest win for hot keys, and it costs almost nothing.
-
Key replication. Store the hottest keys on multiple Redis nodes with a suffix (
code#1,code#2), and have clients pick one at random. -
This spreads one logical key across several physical nodes, so no single Redis instance eats the whole viral load.
-
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.
- You trade analytics precision for near-infinite scale on that one link.
- For a link already doing 50K/sec, the marginal click count matters less than staying up.
The hotter a key gets, the better the local cache works. Hotness is self-defending.
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_atand 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.
"Why 7 characters and not 6 or 8?"
"A plain counter encodes to sequential codes. Isn't that a leak?"
"Your cache hit rate is 99% but one Redis node is overloaded. How?"
"A hot key expires and a thousand requests miss at once. Now what?"
"301 or 302 for the redirect?"
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.