System Design Case Study: A URL Shortener

October 16, 2026 · 11 min read

A URL shortener turns https://example.com/blog/2026/10/a-very-long-path?ref=newsletter into sho.rt/4uSlPp, and redirects anyone who visits the short link. It's the most common first system design question because the product is tiny but the design covers most of the basics: read-heavy traffic, unique key generation, caching, and keeping analytics off the hot path.

1. Requirements

Functional

  • Create a short link for a long URL; optionally a custom alias.
  • Visiting the short link redirects to the original.
  • Links can expire; owners can see click counts.

Non-functional

  • Redirects must be fast (tens of milliseconds) and highly available — a broken short link is a broken link everywhere it was shared.
  • Keys must be unique and hard to guess in bulk.
  • Click counts can be a few seconds stale.

Out of scope: link previews, abuse detection beyond rate limits, user accounts beyond an owner ID. Saying what you're not building is part of the answer.

2. The numbers

Assume 100 million new links a month, 100 reads per write, 500-byte records, five years of retention. The back-of-the-envelope post works these through in detail:

quantityestimatewhat it means
writes~40/sone database handles it easily
reads~4,000/s avg, ~12,000/s peakcache in front, several app servers
storage~3 TB over 5 yearsfits one primary; replicate for availability
hot set~35 GBfits in memory on a cache node

3. API

POST /links            { url, alias?, expiresAt? }  → 201 { key, shortUrl }
GET  /{key}            → 302 Location: <long url>   (or 404 / 410 if expired)
GET  /links/{key}/stats → { clicks, byDay: [...] }

4. Generating keys

This is where most of the interesting choices are.

Hash the URL (e.g. the first 7 characters of a base-62 SHA-256). The same URL always gets the same key, which is nice. But different URLs can collide, so every insert needs a check-and-retry, and two users shortening the same URL share a link — and its stats.

Random keys. Generate 7 random base-62 characters (3.5 trillion possibilities) and insert with a uniqueness constraint; on the rare collision, try again. Simple and unguessable, and it costs nothing extra until the table is very full.

A counter, encoded in base 62. Unique by construction, and no collision checks. A single global counter would be a bottleneck, so each app server leases a block — say a million IDs — from a coordination service, then hands them out locally. The downside: sequential keys are guessable, so anyone can enumerate links. Shuffle the bits with a reversible permutation before encoding if that matters.

Any of these works. Say which you picked and why. Counter blocks are the most common interview answer because they scale without collisions; random keys are the simplest to operate.

How long should the key be?

Base 62 (a–z, A–Z, 0–9) gives 62 choices per character:

lengthpossible keysenough for
6~57 billion~47 years at 100M links/month — tight once you add random-key collisions
7~3.5 trillioneffectively forever at this scale
8~218 trilliononly if keys are random and you want collisions vanishingly rare

With random keys, what matters is how full the space is. At 6 billion links (five years of traffic) in a 3.5-trillion space, the chance a new random key collides is about 0.17% — one retry in every ~600 inserts. That's fine. At 6 characters it would be over 10%, and every tenth create would need a second round trip.

Counter blocks in code

The block allocator is a single row in a database (or a key in ZooKeeper or etcd) that each app server bumps atomically:

-- one row; each server claims the next million IDs
UPDATE id_blocks
SET next_start = next_start + 1000000
WHERE name = 'links'
RETURNING next_start - 1000000 AS block_start;
const ALPHABET =
  '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

function toBase62(n) {
  let s = '';
  do {
    s = ALPHABET[n % 62] + s;
    n = Math.floor(n / 62);
  } while (n > 0);
  return s.padStart(7, '0');
}

class KeyAllocator {
  constructor(db) { this.db = db; this.next = 0; this.end = 0; }

  async nextKey() {
    if (this.next >= this.end) {
      const { block_start } = await this.db.one(CLAIM_BLOCK_SQL);
      this.next = Number(block_start);
      this.end = this.next + 1_000_000;
    }
    // Permute before encoding so consecutive IDs don't give
    // consecutive, guessable keys.
    return toBase62(permute(this.next++));
  }
}

The coordination service is touched once per million links, so it's never the bottleneck. If a server crashes mid-block, the unused IDs are simply lost — with 3.5 trillion keys, nobody will miss them.

For permute, any bijection on the ID range works: multiply by a large odd constant modulo 62⁷, or run a small Feistel network. It isn't encryption, just enough to stop someone walking the key space by adding one.

Custom aliases

A custom alias like sho.rt/launch shares the same key column, so the primary key's uniqueness constraint handles conflicts for free. Two rules keep it safe: reserve aliases that collide with your own routes (/api, /login, /links), and keep custom aliases in a character set or length that generated keys never use — otherwise a user can claim the alias your counter is about to produce.

5. Data model

One table, keyed by the short key:

CREATE TABLE links (
  key        VARCHAR(10) PRIMARY KEY,
  long_url   TEXT NOT NULL,
  owner_id   BIGINT,
  created_at TIMESTAMPTZ NOT NULL,
  expires_at TIMESTAMPTZ
);

The only hot query is a lookup by primary key. Nothing joins. A relational database is fine at this scale; a key-value store (DynamoDB, Cassandra) is also a natural fit, and makes sharding by key trivial if it's ever needed.

6. The two request paths

client
service
cache
database
analytics
POST /links { url }
▶

Creating a link: the client sends the long URL. The service is stateless, so any instance behind the load balancer can take it.

0 / 7

301 or 302? A 301 (permanent) lets browsers cache the redirect, so repeat visits never reach you — fast, but those clicks go uncounted. A 302 (temporary) brings every click back. Most shorteners choose 302 because analytics are part of the product.

The redirect handler is short, and that's the point — everything that isn't "find the URL and answer" is pushed somewhere else:

app.get('/:key', async (req, res) => {
  const { key } = req.params;

  let link = await cache.get(key);
  if (link === undefined) {
    link = await replica.oneOrNone(
      'SELECT long_url, expires_at FROM links WHERE key = $1', [key]
    );
    // Cache misses too (as null), briefly, so random keys can't
    // hammer the database.
    await cache.set(key, link, { ttl: link ? 3600 : 60 });
  }

  if (!link) return res.status(404).send('Not found');
  if (link.expires_at && link.expires_at < Date.now()) {
    return res.status(410).send('This link has expired');
  }

  // Fire-and-forget: the user never waits for analytics.
  clicks.publish({ key, at: Date.now(), ref: req.get('Referer') });

  res.set('Cache-Control', 'private, max-age=0');
  return res.redirect(302, link.long_url);
});

Two lines in there are worth defending in an interview. clicks.publish must not be awaited on anything slower than a local buffer, or an analytics outage becomes a redirect outage. And the Cache-Control header matters with a 302: without it, some proxies will cache the redirect anyway, and your click counts quietly drop.

7. Scaling the read path

  • Cache: a read-through cache keyed by short key, with a TTL. Popular links are a tiny fraction of all links, so the hit rate is very high. See caching strategies.
  • Replicas: cache misses go to read replicas, not the primary.
  • CDN or edge: for very hot links, the redirect can be served from the edge with a short cache lifetime.
  • Negative caching: cache "not found" too, briefly, so someone hammering random keys can't push every miss through to the database.

8. What breaks first, and what you'd do

  • A single viral link makes one cache key very hot. Replicate hot keys, or keep a small in-process cache on each app server.
  • Abuse: people creating millions of links, or links to malware. Add per-owner rate limits on creation, and check URLs against a blocklist asynchronously.
  • Analytics volume: 4,000 clicks a second written one row at a time would dominate the database. Publish click events to a log and aggregate them in batches — counts a few seconds behind are fine.
  • Expiry: don't delete in the request path. Return 410 for expired keys and clean up with a background job.

9. The analytics pipeline

Click counts are where a "tiny" product quietly becomes a data system. At ~4,000 clicks a second, that's ~350 million events a day.

redirect servers ──► log (Kafka / Kinesis) ──► stream aggregator ──► click_counts
                                            └──► object storage (raw events, for reprocessing)
  • Redirect servers append events to a local buffer and flush to the log in batches. If the log is down, they drop or spool to disk — they never block the redirect.
  • The aggregator consumes events and keeps per-key, per-minute counters in memory, flushing them every few seconds as upserts: UPDATE click_counts SET clicks = clicks + $n WHERE key = $k AND minute = $m. One write per hot key per flush instead of one per click.
  • Raw events go to cheap object storage, partitioned by day. When someone asks a new question ("clicks by country last quarter"), you reprocess them instead of wishing you'd counted it.

Because the log delivers at least once, the aggregator can count a click twice after a crash. For a click counter that's usually acceptable; if it isn't, commit consumer offsets in the same transaction as the counter update — see message queues vs event logs.

10. Growing beyond one database

The numbers above fit one Postgres primary with replicas for years. If they didn't — say 10× the writes, or a requirement to keep links forever — the move is to shard by key:

  • Shard key = short key. Every query is a lookup by key, so every query hits exactly one shard. There are no cross-shard queries to worry about.
  • Use consistent hashing or a fixed number of logical shards mapped onto physical nodes, so adding capacity moves a fraction of the data, not all of it.
  • Owner queries ("show me all my links") now fan out to every shard. Keep a small secondary table keyed by owner_id instead, written alongside the link.

See sharding and replication for the general trade-offs.

Multiple regions

Short links are shared globally, so a visitor in Mumbai shouldn't wait for a round trip to Virginia. Reads are the easy part: replicate the links table to every region and serve redirects locally. Writes are rarer, and can go to a single home region — a newly created link appearing in other regions a second later is fine, except for its creator, who will click it immediately. Route the creator's first redirect to the home region, or write the new link into the local region's cache at creation time. That's the read-your-writes problem in miniature.

11. Trade-offs to state out loud

decisionchoice herewhat you give up
key generationcounter blocks + permutationa coordination service; lost IDs on crash
redirect status302more traffic than 301 — every click returns
databaseone relational primary + replicasneeds sharding past a few TB of writes/day
analyticsasync, batched, a few seconds stalereal-time exact counts
expirychecked on read, cleaned up laterdead rows linger until the cleanup job runs

12. Follow-up questions to expect

  • "Make keys unguessable." Random keys, or counter + a keyed permutation. Then add rate limits on 404s per IP, so scanning is slow.
  • "Two users shorten the same URL — same key?" Only if the product wants it. Shared keys share stats and expiry, which usually isn't what either user expects. A unique index on (owner_id, long_url) gives per-user dedup without the surprise.
  • "A link points to malware after it was created." The destination can change after you checked it. Re-scan popular destinations periodically, and keep a kill switch that flips a key to 410 instantly — which means the cache must be invalidated, not just left to expire.
  • "What if the cache cluster dies?" Everything falls through to the replicas at once. Size replicas for a degraded hit rate, and protect them with load shedding rather than letting the whole fleet fall over together.
  • "How would you test this?" Load-test the redirect path with a realistic key distribution — a few very hot keys and a long tail — not uniform random keys, which make the cache look worse and the database look better than reality.

The pattern underneath all of this: keep the redirect path to one cache lookup and one response, and move every other concern — counting, scanning, cleanup — off to the side where it can be slow, retried, or temporarily broken without anyone noticing.

Practise it

Try this exact problem in the system design practice tool: write your notes, draw the architecture with the diagram editor, and copy both into Claude for a review against the same rubric used here.