Skip to content

Design a URL shortener

SDE II45 min readNeeds: Caching, Partitioning

Functional:

  • Given a long URL, return a short code. Visiting the short URL redirects to the long one.
  • Codes never change once issued. Custom aliases and expiry are out of scope for v1.

Non-functional:

  • Redirects must be fast (p99 under 50 ms server time) and highly available.
  • Reads far outnumber writes.

Assume 100 million new URLs a month and a 100 to 1 read to write ratio.

  • Writes: 100M / (30 × 86,400 s) ≈ 40 per second on average.
  • Reads: about 4,000 per second on average; plan for a few times that at peak.
  • Codes: 10 years × 1.2B a year = 12B codes. Seven base62 characters give 62⁷ ≈ 3.5 trillion, so 7 characters is plenty.
POST /api/urls { "longUrl": "https://..." } -> 201 { "code": "x7Kp2" }
GET /{code} -> 301 or 302, Location: https://...

One table keyed by code: code (PK), long_url, created_at. Lookups are always by code, which makes it a good fit for a key-value store or a relational table with a primary key. We start with PostgreSQL and revisit when a single primary can’t keep up.

Writes go through the app servers to the database. Redirects are answered from Redis whenever possible and fall back to the database on a miss.

Architecture: URL shortener (v1)
User Load balancer App servers stateless, x N Cache code to long URL URL store source of truth ID service hands out ranges 1. read 2. on miss GET /x7Kp2
Scroll or pinch to zoom, drag to pan
Drawn in the Excalidraw style. Each app server takes a block of IDs from the ID service, so codes never collide.

Each short code comes from a counter range handed out by the ID service, then base62 encoded:

Base62.java
public final class Base62 {
private static final String ALPHABET =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
/** Turns a unique counter value into a short code. 62^7 is about 3.5 trillion codes. */
public static String encode(long id) {
if (id < 0) throw new IllegalArgumentException("id must be non-negative");
if (id == 0) return "0";
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(ALPHABET.charAt((int) (id % 62)));
id /= 62;
}
return sb.reverse().toString();
}
public static long decode(String code) {
long id = 0;
for (char c : code.toCharArray()) {
int digit = ALPHABET.indexOf(c);
if (digit < 0) throw new IllegalArgumentException("Bad character " + c);
id = id * 62 + digit;
}
return id;
}
public static void main(String[] args) {
String code = encode(125_000_000_000L);
System.out.println(code + " " + decode(code)); // 2crtgcg 125000000000
}
}
  • What happens when one link goes viral and a single cache key takes 50,000 reads a second.
  • Making the ID service highly available without handing out the same range twice.
  • A single database primary for writes. At about 40 writes a second this is far off, but partitioning by code is the path when it isn’t.
  • Cache memory: the hot set, not every URL, needs to fit.

A summary of every decision on this page and what it gave up. Filled in with the full write-up.