System Design Interview: Designing a URL Shortener

A complete system design walkthrough for a URL shortener like bit.ly — requirements, estimation, API, ID generation, database choice, caching, redirects, analytics and scaling.

By Danyal Ahmed - Data Scientist · · 4 min read

"Design a URL shortener" is one of the most common system design interview questions, and a great practice problem. It's simple enough to finish in 45 minutes but touches the core topics: estimation, ID generation, storage, caching, read-heavy scaling and analytics. Here's a complete walkthrough using the approach from system design fundamentals.

Step 1: Requirements

Functional

  • Given a long URL, create a short URL (https://sho.rt/aZ3kQ9x).
  • Visiting the short URL redirects to the long URL.
  • Optional: custom aliases, expiration dates, click analytics, user accounts.

Non-functional

  • Very low redirect latency. Redirects sit in the user's critical path.
  • High availability for redirects. Broken links are highly visible.
  • Durability. Links must never be lost.
  • Short codes should be hard to guess if links may be private.
  • Read-heavy: far more redirects than creations.

Step 2: Estimates

Assume:

  • 100 million new URLs per month → ~40 creations/second average.
  • A read:write ratio of 100:1 → ~4,000 redirects/second average, with peaks around 10–20k/s.
  • 5 years of retention → 100M × 12 × 5 = 6 billion URLs.
  • 500 bytes per record (long URL + metadata) → **3 TB** of storage.

Conclusions: storage is modest, writes are modest, and reads dominate, so caching is key. See capacity planning for the estimation method.

Step 3: API

httpPOST /api/v1/links
{"long_url": "https://example.com/some/very/long/path", "custom_alias": null, "expires_at": null}
→ 201 {"code": "aZ3kQ9x", "short_url": "https://sho.rt/aZ3kQ9x"}

GET /{code}
→ 301/302 Location: https://example.com/some/very/long/path

GET /api/v1/links/{code}/stats
→ {"clicks": 1532, "by_country": {...}, "by_day": [...]}

301 vs 302: a 301 (permanent) lets browsers and intermediaries cache the redirect, which reduces load but means you may not see every click. A 302 (temporary) sends every click through your servers, which is better for analytics and for changing destinations. Many shorteners use 302 or 307 for this reason.

Step 4: How long should codes be?

Using Base62 (a–z, A–Z, 0–9):

text62^6 ≈ 56.8 billion
62^7 ≈ 3.5 trillion

Seven characters give ample room for 6 billion URLs, with space left over for sparse, hard-to-guess codes.

Step 5: Generating unique codes

This is the heart of the problem. Options:

Option A: Hash the long URL

Take a hash (such as SHA-256) of the URL, Base62-encode it and use the first 7 characters.

  • ✅ The same URL always maps to the same code (natural deduplication, if desired).
  • ❌ Collisions: different URLs can share a 7-character prefix, so you must detect them and retry with a salt.

Option B: Auto-increment counter + Base62

Use a database sequence: ID 125,000,001 → Base62 → 8rS3x.

  • ✅ No collisions; simple.
  • ❌ Predictable, enumerable codes: anyone can iterate through all links. That's a problem for private links.
  • ❌ A single sequence can become a coordination bottleneck at very high scale (mitigated by allocating ID ranges to servers).

Option C: Random codes with a uniqueness check

Generate 7 random Base62 characters and insert with a unique constraint; retry on conflict.

  • ✅ Unpredictable codes.
  • ✅ With a huge keyspace relative to the number of links, collisions are rare, and a retry is cheap.

Option D: Pre-generated key service

A background service generates unique random codes in advance and hands out batches to application servers.

  • ✅ Fast creation, no collision handling in the hot path.
  • ❌ More moving parts.

Recommendation: random codes (option C) with a unique constraint are simple, unguessable and scale well. Move to range allocation or a key service only if creation volume demands it.

pythonimport secrets, string
ALPHABET = string.ascii_letters + string.digits

def new_code(length=7) -> str:
    return "".join(secrets.choice(ALPHABET) for _ in range(length))

def create_link(long_url: str) -> str:
    for _ in range(5):
        code = new_code()
        if db.insert_if_absent("links", code=code, long_url=long_url):
            return code
    raise RuntimeError("could not allocate code")

Step 6: Storage

The access pattern is a key-value lookup by code, with a simple schema:

sqlCREATE TABLE links (
  code        VARCHAR(10) PRIMARY KEY,
  long_url    TEXT NOT NULL,
  user_id     BIGINT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at  TIMESTAMPTZ
);
  • A relational database handles this comfortably at the estimated scale, especially with read replicas and caching.
  • A distributed key-value store is a natural fit if you expect massive scale and want built-in horizontal partitioning.

Either works; justify the choice by access patterns and operational simplicity (SQL vs NoSQL). If you shard later, shard by code (hash-based), since every redirect looks up by code (database scaling: replication and sharding).

Step 7: The redirect path (make it fast)

textBrowser ─▶ CDN/edge ─▶ Load balancer ─▶ Redirect service ─▶ Cache (Redis) ─miss─▶ DB
  • Cache code → long_url in Redis with a long TTL. Link popularity follows a power law, so a small cache covers most traffic (caching strategies).
  • Optionally cache redirects at the CDN edge for extremely popular links, if analytics can be collected at the edge.
  • Keep the redirect service stateless and horizontally scaled (how load balancers work).
  • Cache negative lookups (unknown codes) briefly, to blunt enumeration attacks.

Step 8: Analytics without slowing redirects

Don't write to the database synchronously on each click. Instead:

textRedirect service ──(async event)──▶ Queue/stream ──▶ Analytics consumers ──▶ Warehouse / counters

Emit a click event (code, timestamp, country from IP, referrer, user agent) to a queue or log (message queues explained), and aggregate asynchronously. The redirect stays fast even if analytics lags.

Step 9: Extra features and edge cases

  • Custom aliases: check availability, reserve with a unique constraint, and block offensive or reserved words.
  • Expiration: check expires_at on redirect; clean up expired links in background jobs.
  • Abuse: malware and phishing links are a real problem for shorteners. Scan destinations against threat-intelligence lists, rate-limit creation (rate limiting algorithms) and provide abuse reporting.
  • Deduplication: optionally return the existing code when the same user shortens the same URL again.

Step 10: Reliability

  • Replicate the database, with automated failover.
  • Multi-AZ deployment for the redirect service and cache.
  • If the cache fails, the DB must handle the fallback load, so rate-limit and load-test the cold-cache scenario (load testing guide).
  • Back up link data. It's the entire product.

Final architecture

text            ┌──────────── Create path ───────────┐
Client ─▶ LB ─▶ API service ─▶ DB (links, unique code)
            └──────────── Redirect path ─────────┐
Client ─▶ CDN ─▶ LB ─▶ Redirect service ─▶ Redis ─▶ DB (replicas)
                              │
                              └─▶ Click events ─▶ Queue ─▶ Analytics store

FAQ

Why not just use a hash of the URL?

Hash prefixes can collide, so you need collision handling anyway, and identical URLs from different users would share analytics. Random codes with a uniqueness check are simpler and unguessable.

Use cryptographically random codes from a large keyspace (7+ Base62 characters) instead of sequential IDs, and rate-limit lookups for unknown codes.

Should redirects use 301 or 302?

Use 302/307 if you need accurate analytics or might change destinations; use 301 if you want maximum caching and the destination is permanent.

How would you handle 1 million redirects per second?

Aggressive edge caching, a large replicated in-memory cache tier, a horizontally scaled stateless redirect service, a partitioned key-value store for the long tail, and asynchronous analytics ingestion.