System Design Fundamentals · Scalability Basics

Design a URL Shortener

The canonical first system design problem: a single write-heavy, read-heavy service with a simple data model.

  • 17 min read
  • 27 practice questions
  • 1 graded problem
On this page
  1. Description
  2. The first version, and why it breaks
  3. Start from the numbers
  4. The pattern: encode a unique number
  5. Generating IDs at scale
  6. Second flavor: hashing the long URL
  7. The read path
  8. Partitioning the data
  9. Where it breaks
  10. Tradeoffs
  11. Check yourself
  12. Key takeaways
  13. Quick revise
  14. Practice

Description

A URL shortener turns a long link into a short one - sedu.ly/aZ3kQ9 - and sends anyone who clicks it on to the original page. It's one of the most popular first system design questions because it's small enough to finish in 45 minutes, yet it touches almost every core idea: estimating load, generating unique IDs, caching a read-heavy path, partitioning data, and choosing between HTTP redirect codes.

It also looks like a one-table app: store code -> longUrl, redirect on lookup. In this lesson you'll see why that first version breaks at scale, and build the real design step by step:

  1. Start from the numbers - and let them decide where the effort goes.
  2. Encode a unique number instead of guessing codes.
  3. Generate IDs at scale (leased ranges, and Snowflake).
  4. Hashing the long URL instead, and what it costs.
  5. The read path: caching, unknown codes, 301 versus 302.
  6. Partitioning the data, and where the design breaks.

The first version, and why it breaks

The first version most people write is:

String shorten(String longUrl) {
    String code;
    do {
        code = randomCode(6);                  // 62^6 ≈ 56.8 billion possible codes
    } while (db.exists(code));                 // an extra read before every write
    db.insert(code, longUrl);                  // two servers can both pass exists() with the same code
    return code;
}

String resolve(String code) {
    return db.find(code);                      // every click is a database read
}

It works on a laptop. At production scale, each part fails in its own way:

  • The collision check gets worse as the service succeeds. With 1 billion links stored, a fresh random 6-character code clashes with an existing one about 1.8% of the time; at 6 billion, about 10.6%. Retries become routine, and each costs another read.
  • Check-then-insert races. Two app servers can both see exists(code) == false and both insert - one link silently overwrites the other, or an insert fails with an error nobody handles.
  • Every click reads the database. Redirects outnumber new links by 100 to 1 or more, and they all land on one table.

A system design answer is the set of decisions that fix these, made in the order the numbers force them.

Start from the numbers

Assume 100 million new links a month, each clicked about 100 times:

QuantityEstimate
Writes100M / (30 × 86,400 s) ≈ 40 per second
Redirects100 × 40 ≈ 4,000 per second on average - plan for several times that at peak
Links after 5 years100M × 12 × 5 = 6 billion
Storage6B × ~500 bytes ≈ 3 TB
Code length62^5 ≈ 916 million is too few; 62^6 ≈ 56.8 billion covers 6 billion with room to spare

Two conclusions fall straight out. Writes are modest, so the design effort goes into the read path. And 3 TB and growing won't stay on one database node, so the data will eventually be partitioned.

Here is where the lesson ends up - each box is explained below:

lease ID blocks

click events

Browser

Load balancer

App servers
stateless

ID counter

Cache
code to URL, LRU

Links table
sharded by code

Queue

Analytics

lease ID blocks

click events

Browser

Load balancer

App servers
stateless

ID counter

Cache
code to URL, LRU

Links table
sharded by code

Queue

Analytics

The pattern: encode a unique number

Instead of guessing a code and checking it, give every link a unique integer ID and write that ID in base 62 - digits, uppercase and lowercase letters, all safe in a URL. Uniqueness comes from the ID, so there is nothing to check:

final class Base62 {
    private static final String ALPHABET =
            "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

    static String encode(long id) {                          // id >= 0
        StringBuilder sb = new StringBuilder();
        do {
            sb.append(ALPHABET.charAt((int) (id % 62)));     // least significant digit first...
            id /= 62;
        } while (id > 0);                                    // do-while: 0 encodes as "0", not ""
        return sb.reverse().toString();                      // ...so reverse at the end
    }

    static long decode(String code) {
        long id = 0;
        for (int i = 0; i < code.length(); i++) {
            id = id * 62 + ALPHABET.indexOf(code.charAt(i));
        }
        return id;
    }
}

encode(125) is "21" (2 × 62 + 1), and decode(encode(id)) == id for every non-negative long - distinct IDs always give distinct codes. The two classic bugs: dropping the reverse() (codes stay unique, but decode("12") returns 64, not 125), and a plain while loop that encodes 0 as the empty string.

Where the ID comes from is the real design question.

Generating IDs at scale

A database sequence is the simplest source, but one counter serializes every write and is a single point of failure. The usual fix is range leasing: each app server claims a block of IDs with one atomic increment and hands them out from memory.

interface SharedCounter { long addAndGet(long delta); }   // e.g. UPDATE id_counter SET value = value + 1000 RETURNING value

class IdRangeAllocator {
    private static final long BLOCK = 1_000;
    private final SharedCounter counter;
    private long next = 0, end = 0;                       // this server's current block: [next, end)

    IdRangeAllocator(SharedCounter counter) { this.counter = counter; }

    synchronized long nextId() {
        if (next == end) {                                // block used up (or first call): lease the next one
            end = counter.addAndGet(BLOCK);
            next = end - BLOCK;
        }
        return next++;
    }
}

One counter round trip now covers 1,000 links. If a server crashes halfway through its block, the rest of that block is never issued - the code space gets a gap, which is harmless: codes must be unique, not contiguous or ordered. (Flickr's "ticket servers" use a related trick: two MySQL servers hand out odd and even IDs respectively, so one can fail without stopping ID generation.)

Read more — IDs with no shared counter at all (Snowflake)

Twitter's Snowflake scheme builds a 64-bit ID from parts each machine knows on its own: a millisecond timestamp (41 bits - about 69 years from a custom epoch), a worker ID (10 bits - up to 1,024 generators) and a per-millisecond sequence (12 bits - 4,096 IDs per millisecond per worker).

class SnowflakeIds {
    private static final long EPOCH = 1_735_689_600_000L;   // 2025-01-01T00:00:00Z, a custom epoch
    private final long workerId;                             // 0..1023, unique per running generator
    private long lastMs = -1, sequence = 0;

    SnowflakeIds(long workerId) { this.workerId = workerId; }

    synchronized long nextId() {
        long now = System.currentTimeMillis();
        if (now < lastMs) throw new IllegalStateException("clock moved backwards");   // would reissue old IDs
        if (now == lastMs) {
            sequence = (sequence + 1) & 4095;                // 12 bits
            if (sequence == 0) {                             // 4,096 IDs already issued this millisecond
                while ((now = System.currentTimeMillis()) <= lastMs) { }   // spin to the next millisecond
            }
        } else {
            sequence = 0;
        }
        lastMs = now;
        return ((now - EPOCH) << 22) | (workerId << 12) | sequence;
    }
}

Two rules keep it unique:

  • Sequence used up: after 4,096 IDs in one millisecond, wait for the next millisecond. Wrapping to 0 in the same millisecond would repeat that millisecond's first ID.
  • Clock moved backwards (after an NTP correction, say): refuse - or wait until the clock passes lastMs. Carrying on would produce timestamps already used and reissue old IDs.

The catch for a shortener: the timestamp sits in the high bits, so the IDs are large and sparse. Base62-encoded, a Snowflake ID is 9 characters a day after the epoch, 10 within a year and 11 after a decade. You trade short codes for coordination-free generation - a good deal for tweet IDs, a poor one when shortness is the product.

Second flavor: hashing the long URL

Some products want "the same long URL always gets the same code." Hashing does that naturally: MD5(longUrl) is a function of the URL. But its 128 bits don't fit in 6 characters, and 6 base62 characters keep only about 36 of them - so different URLs start colliding just as random codes do (a 50% chance of at least one clash after roughly 280,000 links, by the birthday bound). The hash design still needs clash handling: on a clash, re-hash with a salt (longUrl + "#1", and so on).

The counter design gets deduplication differently - an index on the long URL, looked up before issuing a new ID. That's an extra read per write, paid only if the product needs it.

Whichever generator you use, let the database enforce uniqueness. A UNIQUE (or primary-key) constraint on code turns check-then-insert into one atomic insert that either succeeds or reports a duplicate you can retry - no separate exists read, no race. The same constraint catches custom aliases such as /abc12 that happen to equal a code the generator produces later; many services also keep aliases out of the generator's space entirely, for example by requiring a -, which base62 never emits.

The read path

GET /aZ3kQ9
  -> load balancer -> any app server (stateless)
       -> cache: code -> longUrl             hit  -> 302, Location: longUrl
       -> miss: database lookup by code      -> put in cache -> redirect
       -> not in the database                -> 404
DatabaseCacheApp serverBrowserDatabaseCacheApp serverBrowseralt[cache hit][cache miss]GET /aZ3kQ9get aZ3kQ9long URLnothingSELECT long_url WHERE code = 'aZ3kQ9'long URL (or no row: 404)set aZ3kQ9302 Location: long URL
DatabaseCacheApp serverBrowserDatabaseCacheApp serverBrowseralt[cache hit][cache miss]GET /aZ3kQ9get aZ3kQ9long URLnothingSELECT long_url WHERE code = 'aZ3kQ9'long URL (or no row: 404)set aZ3kQ9302 Location: long URL
  • Cache-aside fits unusually well. Reads dominate, clicks are heavily skewed toward a few hot links, and a code's destination rarely changes - so a cache far smaller than the dataset serves most requests, and invalidation is rare. An LRU cache keeps the hot set.
  • Unknown codes are the blind spot. Cache-aside only stores what it found, so a code that doesn't exist misses every time and reaches the database. Caching "not found" for a short TTL handles repeated bad codes; a flood of distinct made-up codes needs a Bloom filter of existing codes (it has no false negatives, so "definitely absent" can skip the database) and per-client rate limits.
  • App servers stay stateless. Any server can handle any request because shared state lives in the database, the cache and the ID counter, so scaling out means adding servers behind the load balancer. State that is disposable is fine - a leased ID block. State a request depends on is not - click counts kept in one server's memory are split across servers and lost on restart. Send click events to a shared store or a queue instead.

301 or 302?

301 Moved Permanently302 Found (or 307)
Browser cachingCacheable by default - repeat clicks may never reach youNot cached unless headers allow it
Load on the serviceLowerEvery click
Per-click analyticsMissing for cached repeatsComplete
Editing or taking down a linkBrowsers that cached it keep going to the old targetTakes effect on the next click

A shortener that sells analytics, lets users edit links or must take down phishing links sends 302 (or 307). One that only wants the least load can send 301 - preferably with a bounded Cache-Control: max-age, so a cached redirect eventually expires.

Partitioning the data

At ~3 TB and growing, the table is split across shards. Nearly every query is a point lookup by code, so the shard key is the code (or its ID): hash(code) mod N sends each lookup to exactly one shard and spreads writes evenly. Sharding by ID range looks natural with a counter, but every new ID falls in the highest range, so the newest shard takes all the inserts. Sharding by the destination's domain puts every link to a popular site on one shard - and a lookup by code wouldn't know which shard to ask.

Read more — adding a node without a stampede

With hash(key) mod N, a key stays put only if hash mod 10 == hash mod 11, so going from 10 to 11 nodes moves about 10/11 ≈ 91% of keys. For a cache tier that means most hot keys miss at once and the database takes nearly the whole read load - a self-inflicted outage. Consistent hashing places nodes and keys on a ring and gives each key to the next node clockwise; the new node takes over only its own arc, about 1/11 ≈ 9% of the keys. Virtual nodes (many ring positions per server) even out how much each server owns.

Where it breaks

  • The cache goes down. With a 98% hit rate, the database serves 2% of reads. Lose the cache and it gets 50 times its usual load - a cache outage becomes a database outage. Replicate the cache, and plan a degraded mode: read replicas, request coalescing, shedding load rather than collapsing.
  • Sequential codes are guessable. Base62 is an encoding, not encryption. Someone holding aZ3kQ9 can try aZ3kQA and walk through other people's links, including "unlisted" ones. Scramble the ID before encoding and rate-limit lookups - or use long random codes for links that must stay private.
  • Abuse. Short links hide their destination, which makes them useful for phishing and spam. Check destinations against a blocklist when links are created, rate-limit creation, and keep takedowns effective - one more reason for 302.
  • Clock trouble for timestamp-based IDs (see Snowflake above).
Read more — scrambling sequential IDs

A permutation of the code space keeps codes unique, short and reversible while destroying the visible order. The simplest: multiply the ID by a constant that shares no factor with 62^7 (so it is divisible by neither 2 nor 31), modulo 62^7 - multiplying by such a constant is a one-to-one mapping on [0, 62^7). Mind the overflow: the product exceeds 64 bits, so use BigInteger or a small Feistel network instead. This stops casual walking but is not cryptographic: anyone who creates two links with consecutive IDs can read the constant off the difference of their codes.

Tradeoffs

GeneratorShort?CoordinationGuessable?Same URL, same code?
Counter or leased ranges + base62Yes, 6-7 charactersOne counter, visited once per blockYes, unless scrambledOnly with an extra index lookup
Snowflake + base62No, 9-11 charactersNonePartly - time-orderedNo
Random codeYes, with 7+ characters for a low clash rateUnique constraint and retryOnly by brute force - keep the space sparseNo
Truncated hashYesUnique constraint and salted retryBy anyone who knows the long URLYes

For redirects: 302 for analytics, edits and takedowns; 301 for the least load.

Going further. The deep dive (next tab) takes the design global: serving several regions and the replication-lag 404, redirects at the CDN edge, the click-analytics pipeline, viral links and request coalescing, choosing the store, deletion and code reuse, and private links.

Check yourself

Try to answer before opening each one - recalling beats re-reading.

1. The estimate says ~40 writes/s and ~4,000 reads/s. A teammate wants to shard the database on day one "for write throughput". What do you say?

Show answer

40 writes per second is easy for a single database node - writes aren't where the pressure is. The load is on the read path, which a cache handles. Sharding will become necessary later because of storage (about 3 TB over five years) and total read capacity, not the write rate. Let the numbers decide where the complexity goes.

2. Why doesn't the counter design need exists(code) before inserting, while random codes do?

Show answer

A counter never issues the same ID twice, and base62 is one-to-one, so distinct IDs give distinct codes - unique by construction. Random codes are unique only by luck, so clashes must be detected - ideally by a UNIQUE constraint at insert time rather than a separate read, which would also race. Keep the constraint in the counter design too: custom aliases can still clash with generated codes.

3. A link was served with 301 and no Cache-Control header. Its owner changes the destination, and some users still reach the old page. Why, and what should the service have done?

Show answer

A 301 is cacheable by default, so browsers that followed it before the edit go straight to the old destination without asking the service - the database and server cache being up to date doesn't help. Editable links should use 302 or 307, or a 301 with a short Cache-Control: max-age so the browser's copy expires.

Key takeaways

  • Estimate first: ~40 writes/s against ~4,000 reads/s says optimize reads; 6 billion links × 500 bytes ≈ 3 TB says partition eventually; 62^6 ≈ 56.8 billion says 6 characters are enough for a dense counter.
  • Base62-encode a unique ID: unique by construction, no clash-check read. Leased ID ranges take the counter off the hot path; Snowflake removes coordination but lengthens codes to 9-11 characters.
  • Let a UNIQUE constraint on the code settle races, retries and custom-alias clashes.
  • Cache-aside on the read path, with a plan for unknown codes (Bloom filter, negative caching, rate limits), and stateless app servers.
  • Shard by a hash of the code; consistent hashing makes adding a node move about 1/N of the keys instead of most of them.
  • 302 keeps analytics, edits and takedowns working; 301 saves load, but browsers remember it.
  • Sequential codes are guessable - scramble them, or use long random codes for anything meant to stay private.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before a design interview, and open the full lesson for anything that doesn't ring a bell.

QuantityEstimateSo...
Writes~40/sOne primary is plenty - don't shard for writes
Redirects~4,000/s average, several × at peakThe read path gets the effort: cache
Links after 5 years6 billion
Storage6B × ~500 B ≈ 3 TBPartition eventually
Code length62⁵ ≈ 916 M too few; 62⁶ ≈ 56.8 B enough6-7 base62 characters

The design

Browser → load balancer → stateless app servers → cache (code → URL, LRU) → links table sharded by a hash of the code; app servers lease ID blocks from a counter; click events go to a queue for analytics.

Pick the ID generator

GeneratorShort?CoordinationGuessable?Same URL → same code?
Counter / leased ranges + base62Yes, 6-7 charsOne counter, once per blockYes, unless scrambledOnly with an extra index lookup
Snowflake + base62No, 9-11 charsNonePartly (time-ordered)No
Random codeYes, 7+ chars for few clashesUnique constraint + retryOnly by brute forceNo
Truncated hashYesUnique constraint + salted retryBy anyone who knows the URLYes

Redirect code

301 Moved Permanently302 Found / 307
Browser cachingBy default - repeats may never reach youNot cached unless headers allow
AnalyticsMissing for cached repeatsComplete
Edits and takedownsCached browsers keep the old targetNext click

Analytics, edits or takedowns → 302/307. Least load → 301 with a bounded Cache-Control: max-age.

The traps

  • Random codes + exists() check: clash rate grows with success (~1.8% at 1 B links, ~10.6% at 6 B) and check-then-insert races. A UNIQUE constraint on the code settles races, retries and custom-alias clashes.
  • Base62 bugs: dropping the reverse(), or a while loop that encodes 0 as "".
  • Unknown codes always miss the cache - negative-cache briefly, Bloom filter for floods, rate limits.
  • Click counts in one server's memory - split across servers and lost on restart.
  • Sharding by ID range sends every insert to the newest shard; hash mod N moves ~91% of keys going 10 → 11 nodes (consistent hashing moves ~1/N).
  • Base62 isn't encryption: sequential codes can be walked.
  • A cache outage at a 98% hit rate sends the database 50× its load.

Staff-level points, one line each

  • Multi-region writes need non-overlapping IDs: one home region for allocation (40 writes/s can afford it) or region bits - 3 bits leave each of 8 regions ~440 billion 7-character codes.
  • Replication lag turns a just-created link into a 404 in another region, and negative caching makes it stick. On a miss, ask the home region; don't negative-cache young codes.
  • Edge redirects (Workers KV, Fastly, CloudFront KeyValueStore) are fastest, but takedowns propagate slowly - short TTLs and purge APIs - and the edge must still emit click events.
  • Never UPDATE clicks = clicks + 1 on the redirect path: emit events asynchronously. 4,000 redirects/s ≈ 346 million events/day ≈ 69 GB/day; dedupe by event ID, filter bots and link-preview crawlers, keep raw events to recompute.
  • Viral links: request coalescing (single-flight) against the stampede; replicate or locally cache the hot key.
  • Store: a partitioned key-value store fits point lookups; keep relational product features (users, teams, billing) off the redirect path.
  • Never reuse a deleted code - old copies live in PDFs and chats. Tombstone it (410 Gone).
  • Private links need ~128 random bits (22 base62 chars): with 6 B links in 62⁷, about 1 random guess in 590 hits some link.

Say it in an interview

  • "40 writes a second and 4,000 reads - so a cache on the read path, and no sharding for writes yet."
  • "I'll base62-encode IDs from leased ranges, so codes are unique by construction, with a unique constraint as the backstop."
  • "302, because we sell analytics and need takedowns to work immediately."
  • "Clicks go to a queue - the redirect never waits on analytics."

Practice

This skill comes with 27 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Design a URL Shortener free

Design a URL Shortener

MediumFreeSystem design problem

Design the backend of a URL shortener. Add components from the palette, connect them with arrows from the caller to what it calls (mark an arrow async when the caller hands work off instead of waiting), set how many instances of each run, and write your key decisions in each…

Practice Design a URL Shortener free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime