SYSTEM DESIGN · DESIGN PROBLEMS · WARM-UPS · EASY · 1 OF 41

Design a URL Shortener: Short Codes, Fast Redirects and Click Counts

A full walkthrough of the classic warm-up with the LADDER method: requirements, scale math for 100 million new links a month at a 100 to 1 read ratio, the API and data model, the high-level design, then the parts interviewers probe: generating unique 7-character codes with base62 and ID ranges, 301 vs 302, caching the redirect path, custom aliases, expiry and abuse.

Reported atOracleAdobe

Try it on the practice board Drag the blocks, wire them up, and check your design against the reference.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Start by listening and pinning down scope. Two core flows: turn a long URL into a short link, and redirect anyone who clicks it. Nice to have: a custom alias, an expiry date and a click count. Non-functional: redirects must be fast, say under fifty milliseconds at p ninety-nine, highly available, and two links must never share a code.

Say out loud what you are leaving out: accounts and billing, editing links after creation, a full analytics dashboard, QR codes. Then ask a clarifying question or two. Are users global? Yes, but one region for writes is fine, since creating links is rare compared with following them. Naming the scope lets the interviewer redirect you early instead of after twenty minutes.

Requirements
  • functional:
  • long URL → short link
  • short link → redirect
  • optional custom alias
  • optional expiry date
  • click count per link
  • non-functional:
  • redirect p99 under 50 ms
  • 99.99% available redirects
  • codes never collide
Out of scope (say it)
  • accounts, teams, billing
  • editing a link after creation
  • a full analytics dashboard
  • (we only count clicks)
  • QR codes, link previews
  • ask: global or one region?
  • → global users, one write
  • region is fine

Ask scale

Ask about scale. Assume a hundred million new links a month. A month is about two and a half million seconds, so that is forty writes a second, about a hundred twenty at peak. With a hundred reads per write, redirects run at four thousand a second, twelve thousand at peak. At roughly five hundred bytes a row, that is six hundred gigabytes a year, about three terabytes over five years.

Now turn numbers into decisions. Five years of links is about six billion codes. Base sixty-two, lowercase, uppercase and digits, gives fifty-seven billion six-character codes and three and a half trillion seven-character ones, so seven characters leaves huge headroom. The hot set of popular links is a few million rows, a few gigabytes, so it fits in one Redis cluster. This is a read-heavy system with tiny writes.

Scale math
new links: 100M / month
  100M / 2.5M s ≈ 40 writes/s
  peak ×3 ≈ 120 writes/s

reads 100:1 → 4,000 redirects/s
  peak ≈ 12,000 redirects/s

row ≈ 500 bytes
  100M × 12 × 500 B ≈ 600 GB/yr
  5 years ≈ 3 TB (plus replicas)
What the numbers say
code length, base62 (a-z A-Z 0-9):
  62^6 ≈ 57 billion
  62^7 ≈ 3.5 trillion
  need 6 billion over 5 years
  → 7 chars, lots of headroom

cache: 20% of links get most
  clicks; hot set of a few
  million rows ≈ a few GB

→ read-heavy, tiny writes

Define API

Define the API. A POST to create a link, taking the long URL and an optional alias and expiry, returning the code and the short URL. A GET on the code that answers with a redirect status and a Location header pointing at the long URL, or four hundred four if it is unknown or expired. And a small stats endpoint for the click count. Rate limit the create endpoint per user or IP.

API
POST /v1/urls
  { long_url, alias?, expires_at? }
→ 201 { code: "aZ3k9Qx",
        short_url }

GET /{code}
→ 302 Location: long_url
→ 404 if unknown or expired

GET /v1/urls/{code}/stats
→ { clicks }

Data model

The data model is one main table keyed by the code, with the long URL, an optional owner, and created and expiry times. Click counts live separately so the hot redirect row is never updated. Every read is a lookup by primary key, so a key-value store like DynamoDB fits well. Postgres also works: three terabytes over five years, sharded by code later if needed. Pick one and say why.

Data model
urls
  code        PK  varchar(10)
  long_url        text
  owner_id        nullable
  created_at, expires_at

click_counts
  code PK, clicks bigint

access: always by code
→ key-value lookup

High-level design

Draw the high-level design. Users hit a load balancer, then a stateless shortener service. To create a link, the service takes the next unique number from its local block of IDs, encodes it in base sixty-two to get a seven-character code, inserts the row, and returns the short link. The ID range service is only contacted occasionally to fetch a new block, which we will expand on shortly.

Now trace a redirect, the hot path. The request lands on any service instance, which checks Redis for the code. On a hit, it returns the redirect in a couple of milliseconds. On a miss, it reads the database, fills the cache and redirects. With a ninety percent hit ratio, only about twelve hundred reads a second reach the database at peak, which a single primary with replicas handles easily.

Deep dive: codes

Deep dive one: generating codes. Option one, hash the long URL and keep seven characters; but truncated hashes collide, so you must check and rehash. Option two, pick seven random characters and insert only if absent; simple, but every create needs a uniqueness check. Option three, a counter encoded in base sixty-two; it never collides, but a single global counter is a bottleneck and the codes are guessable.

The fix is ranges. A small range service, just a database row or ZooKeeper, hands each server a block of a thousand IDs by advancing a counter. The server then counts inside its block in memory, with no network call per link. A crash wastes at most a thousand IDs, nothing against three and a half trillion. If codes must not be guessable, pass the number through a reversible scramble before encoding.

Generating codes: options
  • 1 hash the URL (MD5/SHA),
  • take 7 base62 chars
  • - collisions: check + rehash
  • 2 random 7 chars, insert if
  • absent
  • - a DB check per create
  • 3 counter → base62
  • + no collisions, ever
  • - one counter = bottleneck
  • and guessable
ID ranges + base62
range service (a DB row or
  ZooKeeper) hands out blocks:
  UPDATE next = next + 1000

server counts inside its block
  in memory: no network call
crash wastes up to 1,000 ids:
  fine with 3.5 trillion

unguessable? scramble the id
  with a reversible permutation

Deep dive: redirects

Deep dive two: which redirect status. A 301, moved permanently, lets browsers cache the redirect, so repeat clicks never reach you. Less load, but you lose click counts and cannot expire or disable a link. A three oh two sends every click through you. Since we want click counts and expiry, choose three oh two, and absorb the load with Redis and a short edge cache for very hot links.

Click counting must stay off the hot path. Incrementing a counter row inside each redirect would turn a viral link into a hot row. Instead, emit a click event to Kafka and return immediately. A consumer aggregates counts per code per minute and writes the totals, so fifty thousand clicks a second on one link becomes one write a minute. Also cache not-found results briefly, so scanners guessing codes do not hammer the database.

301 or 302?
  • 301 Moved Permanently:
  • browsers cache it, later
  • clicks never reach us
  • + less load, - no counts,
  • cannot expire the link
  • 302 Found (or 307):
  • every click comes to us
  • + accurate counts, expiry
  • choice: 302, cache in Redis
  • and at the CDN edge
Counting clicks
  • never UPDATE clicks = clicks+1
  • inside the redirect
  • emit a click event to Kafka
  • (fire and forget)
  • consumer aggregates per code
  • per minute, writes totals
  • viral link: 50k clicks/s
  • → one write per minute
  • unknown codes: cache the 404
  • briefly (stops scans)

Deep dive: storage

Deep dive three: the store. At a hundred and twenty writes a second and three terabytes over five years, one primary with a couple of read replicas is honestly enough; the replicas absorb cache misses and take over if the primary fails. When it outgrows that, shard by a hash of the code, so every lookup still touches exactly one shard. Do not range-shard by code: codes from a counter are sequential, so every new link would land on the last shard.

Scaling the URL store
  • today: 120 writes/s peak, 3 TB
  • in 5 years → one primary +
  • read replicas is enough
  • replicas take cache misses
  • and cover primary failure
  • later: shard by hash(code)
  • every lookup hits 1 shard
  • never range-shard by code:
  • new sequential codes would
  • all land on the last shard

Deep dive: edge cases

A few edge cases. Custom aliases are inserted with the alias as the primary key, so a duplicate fails with a conflict; keep aliases from colliding with generated codes, for example by reserving a prefix. Expiry is checked on read, cache entries never outlive it, and a nightly job deletes expired rows. And shorteners attract abuse, so scan new URLs against malware lists and rate limit creation per user and IP.

Aliases, expiry, abuse
  • custom alias: INSERT with the
  • alias as PK; conflict → 409
  • keep aliases out of the
  • counter space (min length)
  • expiry: check expires_at on
  • read; nightly job deletes
  • rows; cache TTL ≤ expiry
  • abuse: scan new URLs against
  • a malware list; rate limit
  • creates per user and IP

Trade-offs

Review the trade-offs. The bottleneck is the redirect read path, so it gets the cache, replicas and edge caching. If Redis dies, the database must survive the full load, so size replicas for it. If the range service is down, servers keep using their current blocks for a while. Monitor redirect latency, cache hit ratio, the four hundred four rate and consumer lag. At ten times the scale, shard by code and add read replicas in more regions.

Trade-offs and failures
  • bottleneck: redirect reads
  • → cache, replicas, edge
  • Redis down → DB takes all
  • reads: replicas sized for it
  • range service down → servers
  • keep using their blocks
  • monitor: redirect p99, hit
  • ratio, 404 rate, create
  • errors, Kafka lag
  • 10x: shard by code, multi-
  • region read replicas

Interview summary

The thirty-second summary. Forty writes and four thousand reads a second, three terabytes over five years. Create with a POST, redirect with a GET returning three oh two. One table keyed by a seven-character code. Codes come from ID blocks encoded in base sixty-two, so there are no collisions and no hot counter. Redirects read Redis then the database. Clicks flow through Kafka into per-minute totals.

Interview summary
  • 40 writes/s, 4k reads/s, 3 TB
  • POST /urls, GET /{code} → 302
  • table keyed by 7-char code
  • codes: ID blocks → base62,
  • no collisions, no hot counter
  • redirect: Redis → DB, 302 for
  • counts and expiry
  • clicks: Kafka → per-minute
  • totals
  • aliases via unique key

Interview questions

How long should the short code be?

Estimate total links over the system lifetime and pick the base62 length that covers it with headroom; seven characters give about 3.5 trillion codes, far more than six billion links over five years.

Why not hash the long URL to make the code?

Truncated hashes collide, so you still need a uniqueness check and a retry, and identical URLs from different users would share a code unless you salt them. A counter or ID block approach avoids collisions entirely.

How do you avoid a single global counter becoming a bottleneck?

Have a range service hand each server a block of, say, a thousand IDs; servers allocate from their block in memory and only fetch a new block when it runs out, wasting at most one block on a crash.

Should the redirect be a 301 or a 302?

A 301 is cached by browsers, which cuts load but loses click tracking and prevents expiring a link. A 302 sends each click to the service, so use it when you need analytics or expiry and absorb the load with caching.

How do you count clicks without slowing the redirect?

Emit a click event asynchronously to a log such as Kafka and aggregate counts per code per time window in a consumer, writing totals periodically instead of updating a counter row on every redirect.

SQL or NoSQL for the URL table?

Access is always a primary-key lookup by code with no joins, so a key-value store such as DynamoDB is a natural fit; a relational database also works at a few terabytes and can be sharded by code later.

How would you support custom aliases?

Insert the alias as the primary key and return a conflict if it already exists, enforce allowed characters and a minimum length, and keep aliases from overlapping the generated code space.

What happens if the cache cluster fails?

All redirects fall through to the database, so replicas must be sized to carry peak read traffic on their own, and the service should use request coalescing and timeouts to avoid a stampede while the cache warms up.