DSA Guide
System Design

URL Shortener

The six-step method applied end to end — ID generation, a 100:1 read ratio, and why the redirect path decides the architecture

The classic first design problem. It looks trivial and it is not: the ID generation question alone has four reasonable answers with different failure modes.

1. Clarify and Scope

Questions worth asking before drawing anything:

QuestionAssumption taken here
Custom aliases?No — system-generated codes only
Do links expire?Yes, optional expiry
Analytics?Click counts only, not real-time
How long are the codes?As short as possible
Can a link be deleted?Yes

In scope: shorten a URL, redirect, count clicks. Out of scope: user accounts, custom domains, editing a link's target.

2. Put Numbers On It

WRITES
  100 million new links per month
  100M ÷ (30 × 100,000 sec)        ≈  33 writes/sec
  × 3 peak                          ≈  100 writes/sec        ← tiny

READS
  Assume each link is clicked 100 times
  10 billion redirects per month
  10B ÷ (30 × 100,000)              ≈  3,300 reads/sec
  × 3 peak                          ≈  10,000 reads/sec

  Read : write                      =  100 : 1               ← the key finding

STORAGE
  short code      7 bytes
  long URL      100 bytes
  metadata      100 bytes  (created, expiry, owner, clicks)
  per row     ≈ 210 bytes, round to 500 with indexes

  100M/month × 500 bytes × 12 × 5 years  ≈  3 TB

What these four numbers decided

FindingConsequence
Only 100 writes/secOne database primary is plenty. No write sharding
10,000 reads/secCache hard. This is the path to optimise
100:1 read ratioEvery design decision should favour reads
3 TB over five yearsFits comfortably on one machine. No sharding needed

The whole system is read-heavy and small. That rules out most of the complexity people reach for.

How long must the code be?

Alphabet: a-z A-Z 0-9  =  62 characters

62^6 =  56 billion
62^7 =  3.5 trillion       ← 7 characters is ample

At 100 million links per month, seven characters lasts thousands of years.

3. Define the Interface

POST /urls
  body:     { "url": "https://example.com/very/long/path", "expiry": "2027-01-01" }
  returns:  { "code": "aB3xK9p", "short": "https://sho.rt/aB3xK9p" }

GET /{code}
  returns:  301 or 302 redirect to the original URL

GET /urls/{code}/stats
  returns:  { "code": "aB3xK9p", "clicks": 1423 }

301 or 302 — this choice has consequences

301 Permanent — browsers cache it. Later clicks never reach your servers at all, which massively cuts load. But you can no longer count clicks, and you can never change where the link points.

302 Found — every click reaches you. You get analytics and can change the target, at the cost of serving all that traffic.

Since this design counts clicks, 302 is the right call. Say it out loud: "302, because we need click counts — a 301 would be cheaper but the browser cache would hide the traffic from us."

4. Generating the Code

This is the real design question, and there are four answers.

ApproachHowProblem
Hash the URLbase62(md5(url))[:7]Collisions must be detected and retried
Random7 random base62 charactersCollisions; needs a uniqueness check
Auto-increment + base62Encode a counterCodes are guessable and leak volume
Counter range per serverEach server claims a block of idsBest balance — no coordination per write

Counter ranges are the strongest answer

A central service hands each application server a block of one million ids. The server encodes them to base62 locally with no coordination per write.

  • No collisions ever — ranges do not overlap.
  • No database check on the write path.
  • A server crash wastes at most one unused block, which is harmless.

The remaining flaw is that sequential ids are guessable and reveal how many links exist. If that matters, multiply the counter by a large odd number modulo 62⁷, which scrambles the order while staying collision-free.

Server A holds 1,000,000 – 1,999,999
Server B holds 2,000,000 – 2,999,999

id 1,000,001  →  base62  →  "4c92"

Why hashing alone is not enough

md5(url) truncated to 7 characters will collide — by the birthday paradox, expect the first collision after roughly √(62⁷) ≈ 1.8 million links, which you reach in under a month.

You then need a database read on every write to check the code is free, plus a retry loop. That turns a coordination-free write into a coordinated one.

5. The Architecture

Building up under pressure
POST /urlsnext idinsertClientLoad BalancerApp ServersstatelessID Servicehands out rangesRediscode -> URLDatabasecode, url, expiryQueueclick eventsAnalytics
ClientEdgeServiceCacheDatabaseQueue
pathWRITErate100/sec
Write path. Take an id from the local range, encode to base62, insert the row. No lookup needed — the code is unique by construction.
1 / 4
The redirect path is the only one that carries real traffic, so it gets the cache.

The database

urls
  code         VARCHAR(7)   PRIMARY KEY
  long_url     TEXT         NOT NULL
  created_at   TIMESTAMP
  expires_at   TIMESTAMP    NULL
  click_count  BIGINT       DEFAULT 0

code as the primary key means the redirect is a single primary-key lookup — the fastest read a database offers. No secondary index is needed on the hot path.

Never increment click_count on the redirect

UPDATE urls SET click_count = click_count + 1 WHERE code = ?

That is a write, with a row lock, on every single redirect. It turns a 100 writes/sec system into a 10,000 writes/sec system, and hot links serialise on one row.

Push click events onto a queue and aggregate them in batches. Counts become a few seconds stale, which the scoping step already accepted.

6. What Was Traded Away

DecisionGainedCost
302 instead of 301Click counts, changeable targetsEvery click hits our servers
Cache in front~90% of reads never reach the DBDeleted links may redirect for up to the TTL
Async click countingWrite load stays at 100/secCounts lag by seconds
Counter rangesNo collision checks, no coordinationCodes are sequential unless scrambled
Single primarySimple, no shardingOne machine's write capacity — ample here

Follow-on Questions

On this page