Skip to content
Katabench
Try free
8 min read The Katabench team

Design a URL shortener: from one API to a billion redirects

Design a URL shortener with real numbers: 40 writes and 4,000 redirects a second, base62 keys, a cache-aside redirect path, and the 301 vs 302 analytics trade.

"Design a URL shortener" is the interview prompt everyone has seen and almost nobody has built. The candidate draws a box labeled API, a box labeled database, says the word "hash," and waits. The interviewer waits too, because the prompt was never about the boxes. A shortener is a dictionary with a public read endpoint, and the entire design lives in three questions: how many reads, how skewed are they, and what has to happen on the redirect path before the browser is allowed to leave.

The same three questions decide whether a real service survives a link going viral. So do the arithmetic first.

Put numbers on it before drawing anything

Assume the product takes 100 million new links a month, and that a link is read far more often than it is written: say 100 redirects for every create.

writes:   100,000,000 links / (30 days x 86,400 s)  =  38.6/s, call it 40/s
reads:    40/s x 100                                =  4,000/s on average
peak:     plan for 10x                              =  40,000 redirects/s
storage:  100,000,000 links x ~500 bytes            =  50 GB/month, 600 GB/year

Forty writes per second is nothing. A laptop running PostgreSQL does that with a unique index and no tuning. Fifty gigabytes a month is a disk purchase, not an architecture: three terabytes over five years, most of it cold. What remains is 4,000 redirects a second on an ordinary afternoon and ten times that when a link lands on the front page of something. Every decision that follows exists to keep that read path short.

Two paths, one product

The redirect is the hot path; everything else can wait

100 reads : 1 write

Redirect (read)

4,000/s average, 40,000/s at peak
  1. Client clicks the short link 100% of clicks
  2. Edge / CDN 60 s max-age on the hottest links serves 30%
  3. API one route, one lookup sees 70%
  4. Cache cache-aside, one-hour TTL hits 90% of those
  5. Database unique index on code sees 7%

Analytics branch

peels off at the API, never awaited
click event bounded channel queue worker counts per hour

a full channel drops the click, not the redirect

Create (write)

40/s, boring on purpose
  1. Client

    POST /links

  2. API

    validate the target, rate limit the caller

  3. Key generator

    base62 of a sequence, or a random code

  4. Database

    insert; the unique constraint is the referee

7 base62 characters: about 3.5 trillion codes

Every hop on the redirect path is a place to lose milliseconds. Answer from the nearest cache, keep the database to one indexed lookup, and let the click count arrive later.

The write path can be boring. The read path has to be fast, and it has to stay fast for the few links that receive most of the clicks. Back-of-the-envelope estimation is the whole first hour of this design, and skipping it is how people end up sharding a 40-write-per-second database.

Generating the key

Seven base62 characters give 62^7, about 3.5 trillion distinct codes. Six give 56.8 billion, which at 1.2 billion links a year would last 47 years. Length is not the constraint; how a code is chosen is.

Base62 of a database sequence. The insert returns a bigint identity, you encode it, and the code is unique by construction with no collision check. The cost is predictability: /abc123 is followed by /abc124, so anyone can enumerate every link ever created. Mixing the sequence through a keyed permutation, or starting it at a large random offset, restores some opacity without giving up uniqueness.

Random codes with a collision check. Generate seven random characters, insert, and retry on a unique-constraint violation. With 1.2 billion codes already in a 3.5 trillion space, the chance a fresh random code collides is about 0.03 percent, so retries are rare and cheap. Random codes are not guessable, which is the property most people actually wanted from the word "hash."

A key generation service. A separate process pre-generates unused codes into a table and hands them to API instances in blocks. No collision check on the request path and no shared sequence, at the price of one more component to operate.

The encoder is twenty lines and worth writing rather than importing:

public static class Base62
{
    private const string Alphabet =
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

    public static string Encode(long value)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(value);

        if (value == 0)
        {
            return "0";
        }

        Span<char> buffer = stackalloc char[11]; // long.MaxValue needs 11 base62 digits
        var index = buffer.Length;

        while (value > 0)
        {
            buffer[--index] = Alphabet[(int)(value % 62)];
            value /= 62;
        }

        return new string(buffer[index..]);
    }
}

Custom aliases share the same column and the same unique constraint. Decide up front whether codes are case-sensitive (base62 says yes; users pasting links into email say no), and reserve the words your own routes use, or someone will register /api.

CREATE TABLE links (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code        text NOT NULL UNIQUE,
    target_url  text NOT NULL,
    owner_id    uuid NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now(),
    expires_at  timestamptz
);

The redirect is the whole product

Traffic to a shortener is brutally skewed: a handful of links are on a billboard, and the long tail was clicked twice in 2024. That skew is what makes a cache-aside layer pay for itself. Suppose the cache answers 90 percent of redirects. The database then sees 400 lookups a second on average and 4,000 at peak, every one of them a primary-key-shaped read against a unique index. One PostgreSQL primary does that without noticing.

Memory is cheap here too. If the hot set is 20 percent of a month's links, that is 20 million entries at roughly 150 bytes each, about 3 GB in a cache node. The caching strategies guide covers invalidation and stampedes; for a shortener both are mild, because a link's destination almost never changes.

app.MapGet("/{code}", async (
    string code,
    HybridCache cache,
    ILinkStore links,
    ChannelWriter<ClickEvent> clicks,
    HttpContext http,
    CancellationToken cancellationToken) =>
{
    var target = await cache.GetOrCreateAsync(
        $"link:{code}",
        async token => await links.FindTargetAsync(code, token),
        new HybridCacheEntryOptions { Expiration = TimeSpan.FromHours(1) },
        cancellationToken: cancellationToken);

    if (target is null)
    {
        return Results.NotFound();
    }

    // Never await analytics here. A full channel drops the click, not the redirect.
    clicks.TryWrite(new ClickEvent(
        code,
        DateTimeOffset.UtcNow,
        http.Request.Headers.Referer.ToString()));

    return Results.Redirect(target, permanent: false);
});

HybridCache gives you an in-process tier, a distributed tier, and single-flight protection, so a thousand concurrent misses on one hot key produce one database read. Cache the negative result too, briefly: scanners probing random seven-character codes should hit memory, not the database.

Then put a CDN in front. A Cache-Control: public, max-age=60 on the hottest redirects lets the edge answer repeat clicks for a minute without asking the origin. If the edge absorbs 30 percent of clicks, 2,800 requests a second reach the API and 280 reach the database. Edge caching is the difference between a viral link being an incident and being a graph.

301 or 302 is an analytics decision

A 301 Moved Permanently tells the browser the answer will never change, and browsers believe it. The second click on that link never reaches your server, and neither does any click after it. Latency is perfect. Analytics are zero, expiry is impossible, and a customer who edits a destination discovers that half their audience is cached on the old one forever.

A 302 Found (or 307 when the method must survive) sends every click back through your service. That is the redirect you want when clicks are the product. RFC 9110 spells out the cacheability rules; the short version is that permanence is a promise, and a shortener can rarely keep it.

A permanent redirect is fast because it removes your server from the loop. It also removes your ability to count, change, or expire anything.

Analytics never touches the redirect path

Four thousand clicks a second is also four thousand analytics events a second, and the temptation is to write each one to the database before returning. Do not. The redirect returns as soon as the destination is known; the click event goes into a bounded in-process channel, a background service drains it into a queue in batches, and a worker aggregates counts per link per hour into a table the dashboard reads. If the broker is slow, the channel fills and clicks are dropped. Losing a few clicks is a bug in a report. Adding 30 ms to the redirect is a bug in the product.

Abuse is a feature list

A shortener is a free open redirect unless you treat it as one. Validate the scheme and host at creation, check destinations against blocklists then and again later (a clean domain today is phishing tomorrow), and give links an expires_at that a sweeper honors. Rate limit creation per API key and per IP; a token bucket forgives a burst of ten links and stops a script creating ten thousand. Custom aliases get the unique constraint, a length floor, and the reserved-word list.

Where to practice the shape

The whiteboard version ends at the boxes. The interesting part is what happens when the boxes carry the numbers above and one of them saturates. Katabench's System Design Studio has a URL Shortener build course that walks this exact progression: shorten the first link, survive the viral link, cache the redirect, count the clicks, redirect from the edge, then observe the whole thing. Each step checks the design against authored rules (no client talking straight to the database, analytics off the redirect path) and runs a deterministic capacity simulation that reports p99 latency, throughput, and which component saturates first under a traffic scenario. It is a model built for learning, not a load test, and there is no single correct diagram. The tracks overview shows where system design sits beside the code puzzles.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.