SE

Design a URL shortener

Build something3 hIntermediate

Classic system design exercise.

The bit.ly clone is the canonical system-design warm-up. Map a short code to a long URL, serve billions of redirects, handle abuse and analytics. The exercise sharpens estimation, key generation, caching, and the read/write split.

The big idea

You need two endpoints and a single mapping table — and then a lot of careful reasoning about scale.

POST /shortenlong → short
GET /:codeshort → 301 redirect

Estimate the load

Before anything else, sanity-check the numbers. Suppose 100 million new short URLs per month and 100× more reads than writes.

Writes:     100M / 30 days / 86_400s ≈ 40 / sec
Reads:      ~ 4000 / sec  (peak: 5×, so ~20k/sec)
Storage:    100M × 500 bytes ≈ 50 GB / month
Reads / yr: 100M × 100 × 12 ≈ 120B redirects / year

Conclusion: writes are trivial; reads dominate; everything is cacheable.

The short code

The short code is the URL path. Two constraints: must be short (~7 chars) and globally unique.

AttributeApproachTrade-off
Hash the URL (MD5 → first 7 chars)Stable for the same URL.Collisions; not all-or-nothing on collision retry.
Random base62 (0-9a-zA-Z)Simple; 62^7 ≈ 3.5T codes.Need collision check on insert.
Counter → base62No collisions by construction; predictable order.Counter is a contention point; reveals total volume.
Pre-allocated rangesCounter sharded across nodes — each grabs a range.Best for high write throughput; adds coordination.

A common choice: a monotonic counter (or Snowflake-style ID) → base62. Predictability is fine for the use case (short links aren't secrets).

The data model

CREATE TABLE links (
  code        TEXT PRIMARY KEY,                  -- 'aZ3xK9p'
  long_url    TEXT NOT NULL,
  user_id     BIGINT REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at  TIMESTAMPTZ,
  is_blocked  BOOLEAN NOT NULL DEFAULT false
);
 
CREATE TABLE click_events (
  id          BIGSERIAL PRIMARY KEY,
  code        TEXT NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  ua          TEXT,
  ip_country  TEXT
);

The links table is small enough to fit in RAM (50GB after a year — still cheap). The click_events table grows fast — push it to a column store (ClickHouse, BigQuery) for analytics; never join against it in the redirect path.

The redirect path

This is the hot path. Optimise it ruthlessly.

CDN edgecache 30s
APIauth + log
Cache (Redis)lookup code
DBfallback
301 redirect
async function resolve(code: string): Promise<string | null> {
  const cached = await redis.get(`link:${code}`);
  if (cached) return cached;
 
  const row = await db.link.findUnique({ where: { code } });
  if (!row || row.is_blocked) return null;
 
  await redis.set(`link:${code}`, row.long_url, 'EX', 3600);
  return row.long_url;
}

Analytics and abuse

  1. Click logging

    Don't write click_events synchronously — emit to a queue from the redirect handler, persist via a worker.

  2. Abuse detection

    Check the long URL against safe-browsing APIs at creation time. Flag domains associated with phishing or malware. Mark is_blocked = true and serve a warning page instead of redirecting.

  3. Rate limiting

    Per-IP or per-user on POST /shorten — bots will try to register millions of codes.

  4. Expiry

    Optional expires_at lets users create temporary links. A background job soft-deletes expired entries.

Scaling cuts

If reads spike beyond what one cache cluster can serve:

  • CDN-cache 301s for a few seconds — many providers do this natively.
  • Shard Redis by code prefix.
  • Read replicas on the DB are gratuitously simple here — reads are idempotent.
  • Pre-warm cache from access logs after a deploy.

In practice

The exercise is less about the implementation and more about the reasoning: estimate first, choose the key strategy with intent, separate the hot read path from the cold analytics path, and decide explicitly which short-link properties matter to you (random vs sequential codes, 301 vs 302, expiry, custom codes).

Key takeaways

  • Estimate load first — back-of-envelope shapes every decision.
  • Counter → base62 is the simplest collision-free short-code scheme.
  • The redirect path must be fast and cached; the analytics path is async.
  • 301 caches in browsers (fast, no analytics); 302 hits your server every time.
  • Push click events to a queue → analytics store; never join them in the redirect.

Checkpoint questions

Use these to test whether the lesson is clear enough to explain without rereading.

  1. 1What do the rough write, read, and storage estimates tell you about the design?
  2. 2Which short-code generation strategy would you choose, and what trade-off does it carry?
  3. 3Why should click analytics be separated from the redirect hot path?
  4. 4When would you return 301 versus 302 for a short link redirect?

References

External resources for going deeper after the lesson above.