System Design Interview: URL Shortener (TinyURL / Bitly)

The URL shortener is one of the most common system design interview questions because it touches every major challenge: unique ID generation, key-value storage, caching, redirects, and analytics. It scales from a side project to billions of links with fundamentally the same architecture.

Functional Requirements

  • Given a long URL, return a unique short URL (e.g., https://ti.co/aB3kX9)
  • Given a short URL, redirect to the original long URL
  • Short URLs expire after a configurable TTL (default: 2 years)
  • Custom aliases (optional): user picks the slug
  • Analytics: click counts, referrers, geography (optional)

Non-Functional Requirements and Scale

Metric Target
Write QPS (URL creation) ~500 /s
Read QPS (redirects) ~50,000 /s (100:1 read/write)
Latency (redirect P99) < 10ms
Storage (5 years) ~150 GB (500 * 86400 * 365 * 5 * ~200 bytes)
Availability 99.99%

Core Design: Short Code Generation

Option 1: Base62 Encoding of an Auto-Increment ID

Assign a globally unique integer ID (from a counter or DB auto-increment), then encode it in base62 (a-z A-Z 0-9).

import string

CHARS = string.ascii_letters + string.digits  # 62 chars

def encode(num: int) -> str:
    if num == 0:
        return CHARS[0]
    result = []
    while num:
        result.append(CHARS[num % 62])
        num //= 62
    return "".join(reversed(result))

def decode(code: str) -> int:
    result = 0
    for ch in code:
        result = result * 62 + CHARS.index(ch)
    return result

# 6 characters covers 62^6 = 56 billion URLs
print(encode(1_000_000))  # "4c92"

Problem with global auto-increment: single point of failure and a bottleneck. Solutions:

  • Ticket server: dedicated counter service with pre-allocated ranges per app server (e.g., server A gets 1–10,000, server B gets 10,001–20,000)
  • Snowflake ID: 64-bit ID composed of timestamp + datacenter ID + machine ID + sequence. Monotonic, unique, no coordination needed

Option 2: Hash + Collision Resolution

Take the first 6 characters of MD5(long_url + salt), check for collision, regenerate if needed. Simpler but random distribution (not monotonic) means no natural ordering and slightly more DB lookups.

Database Schema

CREATE TABLE urls (
    id          BIGINT PRIMARY KEY,          -- Snowflake ID
    short_code  VARCHAR(10) UNIQUE NOT NULL, -- base62-encoded id
    long_url    TEXT NOT NULL,
    user_id     BIGINT,
    created_at  TIMESTAMP DEFAULT NOW(),
    expires_at  TIMESTAMP,
    click_count BIGINT DEFAULT 0
);

CREATE INDEX idx_short_code ON urls(short_code);  -- hash index

Use MySQL or PostgreSQL for the mapping table — it is small (150 GB over 5 years) and fits on a single read-replica cluster. The primary key is the Snowflake ID; short_code is the lookup key.

Caching Layer

Redirects are the hot path (50K QPS). Cache short_code → long_url in Redis:

  • TTL matches the URL expiry
  • Cache hit rate ~95%+ for popular links (Zipf distribution)
  • Cache miss: read from DB read replica, write to cache
  • Eviction: LRU with memory cap (e.g., 50 GB Redis cluster)
def get_long_url(short_code: str) -> str | None:
    # 1. Check Redis cache
    cached = redis.get(f"url:{short_code}")
    if cached:
        return cached.decode()

    # 2. DB fallback
    row = db.query("SELECT long_url, expires_at FROM urls WHERE short_code = %s", short_code)
    if not row or row.expires_at < now():
        return None

    # 3. Populate cache
    ttl = max(1, (row.expires_at - now()).seconds)
    redis.setex(f"url:{short_code}", ttl, row.long_url)
    return row.long_url

Redirect: 301 vs 302

Status Behavior Use When
301 Permanent Browser caches redirect permanently, future requests bypass your server Reduce server load, URL never changes
302 Temporary Browser always checks your server before redirecting Track every click, change destination later

Most URL shorteners use 302 to capture analytics on every redirect and to support retargeting or destination changes.

Analytics Pipeline

Do NOT write analytics synchronously in the redirect path — it adds latency and defeats caching.

# Redirect handler (fast path)
def redirect(short_code):
    long_url = get_long_url(short_code)
    if not long_url:
        return 404

    # Async: fire-and-forget to Kafka
    kafka.produce("url_clicks", {
        "short_code": short_code,
        "timestamp": now(),
        "user_agent": request.headers["User-Agent"],
        "referrer": request.headers.get("Referer"),
        "ip": request.remote_addr,
    })

    return redirect(long_url, status=302)

A Kafka consumer aggregates click events and writes to ClickHouse or BigQuery for analytics. Counter increments to the DB can be batched.

Custom Aliases

Allow users to specify a custom slug (e.g., ti.co/launch2025). Reserve a range of short codes as “user namespace” — validate that the alias matches [a-zA-Z0-9_-]{3,20}, check uniqueness, and insert with the user-provided code instead of the encoded ID.

High Availability and Scaling

           [ DNS / CDN edge ]
                   |
        [ Load Balancer (anycast) ]
           /       |        
    [App]      [App]      [App]     -- stateless, horizontal scale
      |           |          |
   [Redis Cluster (primary + replicas)]
      |
   [MySQL Primary]
      |
   [MySQL Read Replicas x3]
      |
   [Kafka] → [Analytics Consumer] → [ClickHouse]
  • App servers are stateless — any node can serve any request
  • Redis cluster with sentinel for HA; read from replicas
  • MySQL async replication to 3 read replicas; writes go to primary only
  • Snowflake ID generation is distributed — each machine generates its own IDs
  • CDN edge can serve 301-cached redirects without hitting origin

URL Expiry and Cleanup

Run a nightly batch job that deletes or archives rows where expires_at < NOW(). Use a soft-delete column first to confirm before purging. Remove corresponding Redis keys (cache TTL should handle most naturally).

Interview Discussion Points

  • Why base62 over base64? Avoids + and / which are URL-unsafe
  • How do you prevent abuse? Rate-limit by IP/user, block known malicious domains, scan URLs against Safe Browsing API
  • How do you handle hot links? Same URL shortened millions of times — dedup by checking long_url hash before insertion
  • How many chars needed? 7 chars of base62 = 62^7 = 3.5 trillion unique codes — enough for centuries

Frequently Asked Questions

How do you generate unique short codes for a URL shortener?

The two main approaches are: (1) Auto-increment ID + Base62 encoding — assign a globally unique integer (via Snowflake ID for distributed systems) and encode it in base62 (a-z, A-Z, 0-9). 6 characters covers 56 billion URLs. (2) Hash the long URL (MD5/SHA256), take the first 6 characters, and resolve collisions by appending a counter. Base62 encoding of a Snowflake ID is preferred because it is monotonic, collision-free, and does not require database roundtrips to check uniqueness.

Should a URL shortener use HTTP 301 or 302 for redirects?

Use 302 (Temporary Redirect) if you need to track every click or want the ability to change the destination URL later. The browser always contacts your server before redirecting, so you capture analytics. Use 301 (Permanent Redirect) only if you want browsers to cache the redirect indefinitely and skip your server on future visits — this reduces server load but prevents analytics and destination changes. Most commercial URL shorteners (Bitly, TinyURL) use 302 for click tracking and flexibility.

How does caching work in a URL shortener?

Short URL lookups are extremely read-heavy (100:1 read/write ratio) and follow a Zipf distribution — a small fraction of links drive most traffic. Cache short_code to long_url mappings in Redis with a TTL matching the URL expiry. Expect 95%+ cache hit rates for popular links. On cache miss, fall back to the database read replica and populate the cache. The cache effectively absorbs almost all redirect traffic, keeping database load minimal even at 50K+ QPS.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you generate unique short codes for a URL shortener?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The two main approaches are: (1) Auto-increment ID + Base62 encoding — assign a globally unique integer (via Snowflake ID for distributed systems) and encode it in base62 (a-z, A-Z, 0-9). 6 characters covers 56 billion URLs. (2) Hash the long URL (MD5/SHA256), take the first 6 characters, and resolve collisions by appending a counter. Base62 encoding of a Snowflake ID is preferred because it is monotonic, collision-free, and does not require database roundtrips to check uniqueness.”
}
},
{
“@type”: “Question”,
“name”: “Should a URL shortener use HTTP 301 or 302 for redirects?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use 302 (Temporary Redirect) if you need to track every click or want the ability to change the destination URL later. The browser always contacts your server before redirecting, so you capture analytics. Use 301 (Permanent Redirect) only if you want browsers to cache the redirect indefinitely and skip your server on future visits — this reduces server load but prevents analytics and destination changes. Most commercial URL shorteners (Bitly, TinyURL) use 302 for click tracking and flexibility.”
}
},
{
“@type”: “Question”,
“name”: “How does caching work in a URL shortener?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Short URL lookups are extremely read-heavy (100:1 read/write ratio) and follow a Zipf distribution — a small fraction of links drive most traffic. Cache short_code to long_url mappings in Redis with a TTL matching the URL expiry. Expect 95%+ cache hit rates for popular links. On cache miss, fall back to the database read replica and populate the cache. The cache effectively absorbs almost all redirect traffic, keeping database load minimal even at 50K+ QPS.”
}
}
]
}

Companies That Ask This Question

Scroll to Top