YeetCode
System Design · Core Designs

Design a URL Shortener: The System Design Interview Classic

Design a URL shortener like Bitly — capacity math, base62 vs hashing for short codes, the read path, caching, and how to survive a viral link.

9 min readBy Bhavesh Singh
url shortenerbase62 encodingdistributed countercachingcdnread-heavy scaling

This article has an interactive companion

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode

A URL shortener is the "hello world" of system design interviews, which is exactly why it is dangerous. The functional requirements fit in one sentence — turn https://www.example.com/some/very/long/path?utm=whatever into yeet.co/15FTGg and redirect back — so interviewers spend the whole hour on what actually separates engineers: generating a code that never collides, serving billions of reads under 100ms, and surviving a viral link.

Treat every design choice here as a promise with a price. A 301 redirect is faster but blinds your analytics. A hash is stateless but risks collisions. A counter is collision-free but needs coordination. Name the promise, name the price, and the design writes itself.

What are we actually building?

Pin down the scope before drawing a single box. The functional surface is small:

  • Create: accept a long URL, return a short code. Optionally accept a custom alias, an expiry date, and an owner.
  • Redirect: given a short code, send the browser to the original URL.
  • Analytics (secondary): count clicks per code.

The non-functional bounds are where the real system lives:

  • Availability over consistency on the read path. A failed redirect is a dead link a user sees in their browser. It must not happen, even if that means occasionally serving a slightly stale mapping.
  • Redirect latency under ~100ms at p99. People paste these links into tweets and browsers; the hop should feel invisible.
  • Short, non-guessable codes. Sequential 1, 2, 3 codes leak how many URLs exist and let anyone enumerate every link. The encoding has to obscure that.
  • Read-heavy. Reads outnumber writes roughly 100 to 1 — a link is created once and clicked hundreds of times. Every architectural decision optimizes for reads first.

How much traffic and storage is this, really?

Interviewers want the envelope math, not a spreadsheet. Assume 100M new URLs per month and the 100:1 read ratio.

QuantityCalculationResult
Write QPS (avg)100M ÷ (30 × 86,400s)~40 writes/sec
Read QPS (avg)40 × 100~4,000 reads/sec
Storage per entrylong URL (up to 2KB cap) + code + metadata~500 bytes
Monthly storage100M × 500 bytes~50 GB/month
5-year storage50 GB × 60 months~3 TB

The 500-byte row assumes the typical case, not the cap: most real URLs run a few hundred bytes, and 2KB is just the ceiling you validate against, not the average you provision for. Two conclusions fall out immediately. First, 3TB over five years is small — a single sharded database handles it, so storage is never the bottleneck; the access pattern is. Second, peak read traffic (3–5x the average for bursts) is what your caching layer must absorb, since the hot working set is tens of GB and fits comfortably in a Redis cluster.

Generating short codes: hash vs counter

This is the decision the interview hinges on. Map each URL to a unique 6–7 character code drawn from a base62 alphabet (0-9, a-z, A-Z) — seven characters give 62⁷ ≈ 3.5 trillion codes, more than enough headroom. Two families of approaches:

ApproachHow it worksUpsideDownside
HashingMD5/SHA the long URL, take the first 7 chars, base62-encodeStateless, no coordinatorCollisions need retry logic; the same URL can also map to different codes
Counter + base62A global counter increments per URL; encode the integer to base62Guaranteed unique, O(1), shorter codesNeeds a distributed counter

Hashing feels elegant but leaks correctness problems: two different URLs can produce the same 7-char prefix (a collision you must detect and retry), and nothing stops the same URL from getting two codes. A counter encoded to base62 sidesteps both — each integer maps to exactly one code, and each code back to one integer, with zero collision checks. The only cost is producing monotonic integers across a fleet of servers, a solved problem covered below. Pick counter-based and defend it: predictable, collision-free, horizontally scalable.

Here is the encoding itself — take an integer and repeatedly divide by 62, mapping each remainder to a character:

javascript
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; function encodeBase62(n) { if (n === 0) return ALPHABET[0]; // n=0 must encode to '0', not '' let code = ''; while (n > 0) { code = ALPHABET[n % 62] + code; // prepend the low digit n = Math.floor(n / 62); } return code; }

Walkthrough: encoding counter value 1,000,000,000

Say the counter service just handed this request the integer 1,000,000,000 (the billionth URL). Watch the loop peel off one base62 digit per iteration, least-significant first:

Stepn (start)n % 62Charcode so farn → next
11,000,000,00016gg16,129,032
216,129,03242GGg260,145
3260,14555TTGg4,195
44,19541FFTGg67
567555FTGg1
611115FTGg0

The loop stops when n hits 0, and the final code is 15FTGg — six characters for a billion URLs. Two things this makes concrete: the mapping is fully reversible (decode 15FTGg back to 1,000,000,000 without needing a lookup table), and a raw counter produces sequential-ish codes. If non-guessability matters, scramble the integer first — multiply by a large coprime modulo 62⁷, or interleave bits — so consecutive URLs don't produce adjacent codes.

The data model and picking a database

The schema is deliberately flat because the access pattern is a pure key lookup:

sql
short_code VARCHAR(7) PRIMARY KEY, -- e.g. "15FTGg" long_url TEXT, -- the destination, up to 2KB user_id BIGINT, -- owner, nullable for anonymous created_at TIMESTAMP, expires_at TIMESTAMP, -- nullable click_count BIGINT -- updated asynchronously

Every read is "give me the row for this short_code" — no joins, no range scans. That is the textbook shape for a key-value or wide-column store like DynamoDB or Cassandra: single-digit-millisecond point reads, horizontal partitioning by a hash of short_code, tunable consistency. A relational database works fine up to a point, but past roughly a billion rows, manually sharding Postgres becomes an operational tax you didn't need to pay.

How do you make the redirect path fast?

The read path is 99% of your traffic, so it gets the engineering. Layer caches from the edge inward: a CDN / edge cache (Cloudflare Workers, Fastly) terminates redirects close to the user before they ever reach your origin; Redis/Memcached in your data center holds the hot working set with LRU eviction, touched only on a cache miss; the database is the authoritative fallback for cold codes.

The redirect status code is its own trade-off:

StatusBehaviorUse when
301 Moved PermanentlyBrowser caches the redirect; future clicks skip your server entirelyThe destination never changes and you don't need per-click analytics
302 FoundEvery click hits your serviceYou need click tracking or the destination can change

Choosing 301 makes infrastructure cheaper by offloading repeat visits to the browser but goes blind on their analytics; choosing 302 keeps every click visible at the cost of serving all of them yourself. It's a product decision disguised as an HTTP header.

Generating codes across 100 servers without a bottleneck

A single global counter that every write must touch is a single point of failure and a throughput ceiling. The fix is range allocation: a coordination service backed by ZooKeeper or etcd hands each application server a block of one million counter values at a time. Servers increment locally and only call the coordinator when a block runs out — roughly one coordination call per million URLs instead of one per URL, reducing the coordinator to a nearly-idle bookkeeper.

The alternative is a Snowflake-style ID: a 64-bit integer built from timestamp + machine_id + sequence. It needs no coordinator at all, but the codes come out longer since timestamps eat bits. Range allocation wins when short codes matter; Snowflake wins when zero coordination matters more.

Picture one short code getting 500,000 requests/sec — a link in a trending post. All of that traffic hashes to a single partition, hot-spotting one shard while the rest of the cluster sits idle. Detect it by sampling request keys, then attack both sides:

  • Absorb reads at the edge. A high-TTL CDN cache for that one key serves most of the 500k/sec before it reaches your origin.
  • Split the hot key. Replicate the mapping across N cache nodes as short_code#0short_code#N and round-robin reads across them so no single node takes the full load.
  • Promote to in-process cache. Keep the hottest keys in a local LRU on each app server, skipping the network hop to Redis entirely.
  • Decouple the click writes. Don't UPDATE click_count on every hit. Emit each click to a durable stream like Kafka and aggregate asynchronously instead.

The general principle: a redirect must never wait on analytics. Keep click counting off the critical path, and a viral URL becomes a dashboard spike instead of a database outage.

Custom aliases without collisions

When a user requests yeet.co/my-brand, it must not collide with an auto-generated code. Two mechanisms solve it together. First, partition the namespace: reserve a character shape for auto-generated codes (a length the counter hasn't reached, or a purely lowercase alphabet) so custom aliases live in a disjoint space by construction. Second, on insert, use a conditional writeINSERT IF NOT EXISTS — so the store atomically rejects a taken alias and returns a 409 Conflict, collapsing a check-then-write race into one atomic operation.

How this shows up in interviews

The URL shortener is a warm-up that interviewers use to find the edge of your knowledge. Nail the base62 counter and they push on the distributed counter; handle that and they throw the viral hot-key at you. The pattern is always the same: they probe the seam between a diagram that looks right and a system that stays up. Walk one request end to end, name the source of truth out loud, and state which decision you'd reverse if reads-per-write shifted from 100:1 to 10:1.

You can practice this topic on YeetCode to rehearse the whole flow interactively.

Further learning

FAQ

Why use base62 encoding for short codes instead of base64?

Base64 includes +, /, and =, which are unsafe or reserved in URLs and would need percent-encoding, defeating the purpose of a short URL. Base62 sticks to the 62 characters that are always URL-safe — digits and both letter cases — so the code drops straight into a link with no escaping, and 62⁷ ≈ 3.5 trillion combinations is more headroom than a shortener will ever need.

Should a URL shortener use a SQL or NoSQL database?

The access pattern is a pure point lookup by short_code with no joins, which is exactly what a key-value or wide-column store like DynamoDB or Cassandra is built for: millisecond point reads and easy horizontal partitioning by the code's hash. Relational databases work fine at moderate scale, but past roughly a billion rows the manual sharding overhead outweighs the convenience.

How do you avoid two servers generating the same short code?

Give each application server its own pre-allocated range of counter values — a coordination service (ZooKeeper or etcd) hands out blocks of a million integers at a time. A server burns through its block locally and only contacts the coordinator when it needs a new range, so there's no shared counter to contend on and no way for two servers to emit the same integer. The alternative is a Snowflake-style ID that embeds a machine ID for uniqueness without any coordinator at all, at the cost of longer codes.

Does a URL shortener need to store the long URL's full 2KB in every row?

No — 2KB is the validation cap, not the typical row size. Most submitted URLs run a few hundred bytes, so the average stored row (long URL + code + metadata) lands around 500 bytes; only a minority of entries approach the cap. Size the database for the average and simply enforce the 2KB limit at write time so no single row breaks that budget.

Make it stick: run this one yourself

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode