Ch. 19 · System Design

System Design: URL Shortener

Size a URL shortener from 100M links a month, pick between counter-based and random codes, and keep a 4,000 req/s redirect path cheap.

~6 min readintermediateupdated Oct 19, 2026

The URL shortener is a favourite opening question because it looks trivial and isn’t. Everyone can say “hash the URL and store it in a table”. What separates answers is whether you size the problem first, make a deliberate choice about how codes are generated, and notice that the system is almost entirely reads.

This walkthrough follows the order you’d use on a whiteboard: requirements and numbers, the code space, generating codes, the read path, and the extras (analytics, abuse, expiry) that interviewers use as follow-ups.

Pin down the requirements and the numbers

Agree on scope before drawing anything. A reasonable set:

  • POST /links with a long URL returns a short one, like sho.rt/aZ3k9Qx. Optional custom alias and expiry date.
  • GET /aZ3k9Qx redirects to the long URL.
  • Click counts per link, visible to the link’s owner. Approximate and a few minutes stale is fine.
  • Links don’t change destination once created.

Then the numbers. Assume 100 million new links a month and a read-to-write ratio of 100:1, which is typical when links are shared on social media. A quick script in Node 22:

const perMonth = 100e6;
console.log('writes/s avg', (perMonth / (30 * 86400)).toFixed(1),
            ' reads/s at 100:1', (100 * perMonth / (30 * 86400)).toFixed(0));
console.log('5-year rows', (perMonth * 60).toLocaleString('en-US'),
            ' storage at 500 B/row', (perMonth * 60 * 500 / 1e12).toFixed(1), 'TB');
JavaScript
writes/s avg 38.6  reads/s at 100:1 3858
5-year rows 6,000,000,000  storage at 500 B/row 3.0 TB
Text

About 40 writes and 4,000 reads per second on average. Peaks might be five to ten times that, so plan for 40,000 redirects a second at peak. Storage is 3 TB over five years, assuming 500 bytes per row (the long URL averages a couple of hundred bytes, plus owner, timestamps and index overhead). None of these numbers is scary. That’s itself a useful thing to say out loud: a single well-indexed database could hold this, and the real engineering is in making redirects fast and cheap, not in exotic storage.

How long should the code be?

Codes use base62, the characters 0-9a-zA-Z, because they’re URL-safe without encoding. The space grows quickly with length:

62^6 = 56,800,235,584
62^7 = 3,521,614,606,208
62^8 = 218,340,105,584,896
Text

Six characters gives 56.8 billion codes, which at 1.2 billion links a year lasts about 47 years if codes are allocated densely. Seven characters gives 3.5 trillion, about 2,900 years. Seven is the usual pick: the extra character is cheap insurance, and it leaves room for the sparseness that random generation introduces.

Generating codes: counters versus random

There are three common approaches, and the interviewer wants to hear you compare them.

Hash the long URL (MD5 or SHA-256, take the first 7 base62 characters). It’s deterministic, so the same URL always maps to the same code, which deduplicates for free. But two different URLs can produce the same prefix, so you still need collision handling, and deduplication is often unwanted: two users shortening the same URL usually want separate click stats.

Random codes. Generate 7 random base62 characters and insert with a unique constraint on the code column. If the insert fails, generate another. The trap is underestimating collisions. By the birthday bound, the chance of at least one collision among n codes in a space of size N is about 1 - e^(-n²/2N):

after 1,000,000 codes: P(any collision so far)=0.1324  P(next insert collides)=0.000028%
after 10,000,000 codes: P(any collision so far)=1.0000  P(next insert collides)=0.00028%
after 100,000,000 codes: P(any collision so far)=1.0000  P(next insert collides)=0.0028%
after 1,000,000,000 codes: P(any collision so far)=1.0000  P(next insert collides)=0.028%
Text

Read both columns. Collisions are certain to occur once you have millions of codes, so code that assumes uniqueness without checking will eventually overwrite someone’s link. But the chance that any particular insert collides stays tiny even at a billion links (0.028%), so a retry loop costs almost nothing. “Insert, catch the unique-violation, retry” is a perfectly good design. “Check if it exists, then insert” is not, because two servers can pass the check at the same moment.

A counter, encoded in base62. Give every link a unique integer id and convert it. No collisions, ever, and the code is as short as possible.

const A = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function encode(n) { let s = ''; n = BigInt(n); do { s = A[Number(n % 62n)] + s; n /= 62n; } while (n > 0n); return s; }
function decode(s) { let n = 0n; for (const c of s) n = n * 62n + BigInt(A.indexOf(c)); return n; }
for (const id of [1, 61, 62, 125, 3_521_614_606_208]) console.log(id, '->', encode(id), '->', decode(encode(id)).toString());
JavaScript
1 -> 1 -> 1
61 -> Z -> 61
62 -> 10 -> 62
125 -> 21 -> 125
3521614606208 -> 10000000 -> 3521614606208
Text

The last line shows the boundary: id 62⁷ is the first one that needs eight characters. Two concerns come with counters. A single database sequence becomes a coordination point across regions; the usual fix is range allocation, where each app server reserves a block of 10,000 ids at a time from a central counter and hands them out locally. Losing a server wastes its unused block, which doesn’t matter at this scale. The other concern is that sequential codes are guessable: anyone can walk aZ3k9Qx, aZ3k9Qy and scrape every link, including ones people assumed were private. Multiplying the id by a large constant coprime with 62⁷ before encoding turns 1000, 1001, 1002 into rOFLAh2, rQoHeiF, rS7CSki. That hides the sequence from a casual look, but neighbours still share a prefix and anyone who learns the constant can reverse it. If links must be unguessable, use random codes and say so.

The redirect path

At 40,000 redirects a second at peak, every lookup going to the database is avoidable cost. Links are immutable, which makes caching easy: there’s no invalidation problem except deletion and expiry.

  • Put a cache (Redis or Memcached) in front of the table, keyed by code. Link popularity follows a power law, so a cache holding the most recent few million links serves the large majority of hits.
  • Cache negative results briefly too. Bots probing random codes otherwise turn every miss into a database read.
  • Partition the table by code if one database isn’t enough. Codes are uniformly distributed (random) or can be hashed (counter), so range hotspots aren’t a concern.

Then choose the status code. A 301 Moved Permanently lets browsers and intermediaries cache the redirect, so repeat clicks may never reach you: cheapest, but your click counts undercount. A 302 Found (or 307) makes every click hit your service, which is what you want if analytics is a product feature. Most commercial shorteners use 302 for this reason. Interviewers like this question because it ties a protocol detail to a product requirement.

Click analytics shouldn’t sit on the redirect’s critical path. Emit an event (code, timestamp, referrer, coarse location) to a queue and return the redirect immediately; a consumer aggregates counts per link per hour into a separate store. If the analytics pipeline is down, redirects keep working.

Your turn: aliases, two regions and a spam wave

Your shortener runs in two regions. Product wants custom aliases (sho.rt/summer-sale), and security reports that a spammer created 200,000 links to phishing pages last week. What changes?

Answer. Custom aliases share the code namespace, so they go into the same table with the same unique constraint. Creating one is a conditional insert, and a conflict returns 409 with a message that the alias is taken. Across two regions, uniqueness needs a single authority for the namespace: route alias creation to a primary region, or use a globally consistent store for the code column. Counter-generated codes can avoid this by giving each region its own id ranges, but aliases can’t, since two users in different regions could ask for summer-sale at once. Also reserve aliases that collide with your own routes (api, login) and keep aliases out of the counter’s character pattern, for example by requiring at least one hyphen or a minimum length, so they never clash with generated codes.

For abuse: rate-limit link creation per account and per IP, require sign-in for bulk creation, and check destinations against a malicious-URL list (Google Safe Browsing is the common one) at creation time and again periodically, since a clean page can turn malicious later. Give links a disabled flag that the redirect path checks, and since you cache redirects, make disabling evict the cache entry. That’s one more reason to prefer 302: you can’t take back a 301 a browser has already cached.

Where to go next

More in System Design

read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
esc