Filter concepts by levelShowing all levels.

System Design · Section 84

URL Shortener Design

Level
intermediate
Read
12 min
Concepts
1

A URL shortener is the standard interview problem because every decision in it is forced by one measurement: clicks outnumber link creations by roughly a hundred to one, so the redirect path is the design and everything else is secondary. That ratio decides the id scheme (a base62 counter is shortest but sequential and enumerable, a random string is unguessable but needs a uniqueness check, a URL hash deduplicates identical links and still collides), and it decides that collisions are handled by a unique constraint plus a retry rather than a check-then-insert, whose two statements have a race between them. It decides the redirect status code: a 301 is cached by browsers and intermediaries so later clicks never reach you, which is the fastest possible answer and also destroys per-click analytics and any ability to expire or re-point the link, while a 302 keeps every click coming to your server. It decides that the code-to-URL mapping — immutable for the life of a link — is cached aggressively, that click analytics are enqueued or logged rather than written synchronously on the hottest path in the system, and that expiry is an `expires_at` compared at redirect time rather than a delete job that runs later. Abuse prevention is not optional, because a shortener is an open redirect by construction: creation is rate-limited per account and per IP, destinations are screened, and a takedown path exists. And scaling is straightforward in the direction that matters, since a stateless, cacheable redirect tier scales horizontally while twelve writes per second stays comfortably within one primary database for years.

This section

What is true here

  1. The read-to-write ratio (~100:1) decides every other choice; do that arithmetic before designing anything.
  2. Handle code collisions with a unique constraint and a retry — check-then-insert has a race between its two statements.
  3. A 301 is cached by clients and never comes back, so it costs you analytics, expiry and re-pointing; choose 302 whenever those matter.
  4. The code-to-URL mapping is immutable, which makes it an unusually effective cache and keeps the database out of the common path.
  5. Never write analytics synchronously in the redirect handler — it couples the hottest read path to a write.

What you will be able to do

  • Size a shortener from a traffic assumption and read the architecture off the numbers
  • Choose between counter, random and hash id schemes, and justify the code length from the code-space arithmetic
  • Explain the operational consequences of 301 versus 302 for a metered link
  • Place analytics, expiry and abuse checks so that none of them adds latency to the redirect

The whole design, from one ratio

Ids, collisions, redirect semantics, caching, analytics, expiry, abuse and scaling — all derived from reads outnumbering writes.

URL shortener: ids, collisions, redirects, caching, analytics, abuse

coreintermediate

A URL shortener looks trivial and is the standard interview problem because every interesting decision in it is forced by one number: reads outnumber writes by orders of magnitude, so the redirect path is the design and everything else is secondary. The short code can be generated three ways — a counter encoded in base62, which is compact and sequential but leaks how many links exist and lets anyone enumerate them; a random string, which needs a uniqueness check on insert; or a hash of the URL, which gives the same code for the same link and needs collision handling anyway. Collisions are handled by making the code column unique and retrying on violation, not by checking first and then inserting, because the check-then-insert has a race between the two statements. The redirect itself is a choice with consequences: a 301 is cached by browsers and intermediaries so subsequent clicks never reach you, which is fast and destroys your click analytics, while a 302 keeps every click coming to your server, which is slower per click and is what you want if the link can expire or be re-pointed. Reads are served from a cache keyed by short code, which works unusually well here because the mapping is immutable for the life of the link. Analytics are written asynchronously — never in the redirect path, where a slow write would add latency to the one operation that has to be fast. Expiration is a stored timestamp checked at redirect time rather than a delete job, so an expired link answers correctly the moment it expires. Abuse prevention matters because a shortener is an open redirect by definition: rate-limit creation, check destinations against a malicious-URL list, and support takedown. And scaling is easy in the good direction — the redirect path is stateless and cacheable, so it scales horizontally, while writes are low enough volume to stay on one primary for a long time.

Think of it as

A cloakroom ticket. Handing in a coat is rare and can be slow; showing the ticket happens constantly and must be instant. So you design the ticket lookup — a small immutable map from ticket number to peg — and let everything else be leisurely. Every awkward choice in a shortener comes from remembering which of those two operations you are on: creation can afford a uniqueness retry, a malware scan and a database write, and redirection can afford none of them.

sql
CREATE TABLE links (
  code        text PRIMARY KEY,      -- base62, unique
  target_url  text        NOT NULL,
  owner_id    uuid        NOT NULL,
  expires_at  timestamptz,           -- NULL = never
  created_at  timestamptz NOT NULL
);

-- collision handling: let the constraint decide
INSERT INTO links (code, target_url, owner_id)
VALUES ($1, $2, $3)
ON CONFLICT (code) DO NOTHING;
-- 0 rows -> generate another code and try again

What we're doing: Size the system from one assumption, then read the design off the numbers.

shortener-sizing.txttext
Assume: 1M new links/day, 100M clicks/day

writes   1M / 86,400s   ~=      12 per second
reads  100M / 86,400s   ~=   1,160 per second
peak reads (5x average) ~=   5,800 per second
read : write ratio      ~=     100 : 1

storage per link ~ 500 bytes
  1M/day * 365 * 500B  ~=  180 GB per year

What the numbers say:

12 writes/s      -> one primary database is
                    plenty for years
5,800 reads/s    -> trivially cached; the
                    working set of popular
                    codes is small and immutable
180 GB/year      -> no sharding needed early;
                    revisit past a few TB
100:1            -> optimise the read path and
                    nothing else

Code space: 62^7 = 3.5e12 codes. At 1M/day that
is ~9,500 years before 7 characters run out.
6
The 100:1 ratio is the single number that decides the architecture. It is what makes a cache the primary scaling mechanism and makes the write path's cost almost irrelevant.
14
Twelve writes per second is the load an ordinary laptop handles. Recognising this stops a design from reaching for write sharding, distributed id generation or a queue in front of creation — all of which would be real complexity bought against a load that does not exist.
24
Sizing the code space is what turns "how long should the code be" from taste into arithmetic. Seven base62 characters carry 3.5 trillion codes, so the length is set by the collision and enumeration story rather than by capacity.

Why this works: Every design decision in a shortener falls out of the read-to-write ratio and the code-space arithmetic, so doing those two calculations first replaces most of the argument. The numbers also say what not to build: at twelve writes a second, distributed id generation and write sharding are answers to a problem this system does not have.

Checking for a free code, then inserting it

Wrong

python
code = random_code()
if not db.exists(code):        # two statements,
    db.insert(code, url)       # one race between
                               # them

Better

python
for _ in range(5):
    code = random_code()
    if db.insert_if_absent(code, url):  # one atomic
        return code                     # statement
raise CodeSpaceTooFull()

What you see: Two links occasionally share a code under concurrent creation, so one of them silently redirects to the other's destination. It reproduces only under load and looks like data corruption rather than a race.

Why: Between the existence check and the insert, another writer can take the same code, so the check describes a state that is already stale when the insert runs. A unique constraint plus an atomic insert-if-absent moves the decision into the database, where the two operations cannot interleave.

The redirect path, which is the design
misshit

GET /aX9k2

Cache lookup by code

immutable value — high hit rate

Database read on miss

primary key lookup

Expired? blocked?

compared on already-loaded data

302 to target URL

410 Gone

expired or taken down

Enqueue click event

asynchronous, never blocking

  • GET /aX9k2
    • leads to Cache lookup by code
  • Cache lookup by code — immutable value — high hit rate
    • leads to Database read on miss (miss)
    • leads to Expired? blocked? (hit)
  • Database read on miss — primary key lookup
    • leads to Expired? blocked?
  • Expired? blocked? — compared on already-loaded data
    • leads to 302 to target URL
    • on error, leads to 410 Gone
  • 302 to target URL
    • leads to Enqueue click event
  • 410 Gone — expired or taken down
  • Enqueue click event — asynchronous, never blocking

Three ways to generate the short code

Three ways to generate the short code
SchemeUpsideDownside
Counter → base62Shortest codes, no collisions by construction, index-friendlySequential and enumerable; leaks link volume; needs a coordinated counter
Random stringUnguessable, no coordination between writersNeeds a uniqueness check on insert; longer codes as the space fills
Hash of the target URLSame URL yields the same code — natural deduplicationCollisions still possible; identical links share analytics; truncation shortens the space

301 versus 302 for the redirect

301 versus 302 for the redirect
Property301 Moved Permanently302 Found
Cached by browsers and proxiesYes — later clicks may never reach youNo by default
Per-click analyticsOnly the first click per clientEvery click
Can the link be re-pointed later?Not reliably — clients hold the old targetYes
Can the link expire?Not reliably, for the same reasonYes
Load on your serviceLowestOne request per click
Default choiceOnly for permanent, unmetered linksWhenever analytics or expiry matter

Each requirement and where it lives in the design

Each requirement and where it lives in the design
RequirementWhere it is handledEffect on the redirect path
Collision handlingUnique constraint plus retry, at creationNone
Read-heavy cachingCache keyed by short code, immutable valueThe whole point — a hit avoids the database
AnalyticsAsynchronous: enqueue or log, aggregate laterOne non-blocking write
Expiration`expires_at` compared at redirect timeOne comparison on already-loaded data
Abuse preventionRate limit and screen at creation; block list at redirectA set membership check
Horizontal scalingStateless redirect servers behind a load balancerScales with instance count

Remember: Reads outnumber writes roughly 100:1, so the redirect path is the design. Generate codes with a base62 counter, a random string or a URL hash, and handle collisions with a unique constraint plus retry rather than check-then-insert. Choose 302 whenever analytics or expiry matter, because a 301 is cached by clients and never comes back. Cache the immutable code-to-URL mapping, write analytics asynchronously, check `expires_at` at redirect time, rate-limit and screen at creation, and scale the stateless redirect tier horizontally.

See also: cache patterns · id schemes and their tradeoffs · what to estimate · rate limit scope · cache control and invalidation

Advertisement