System Design: URL Shortener (TinyURL / bit.ly)

Designing a URL shortener is one of the most common system design interview questions, especially at the intern and new grad level. It’s a focused problem with clear scope — small enough to cover end-to-end in 45 minutes, deep enough to test your instincts on hashing, databases, caching, and scale.

Step 1: Clarify Requirements

Before drawing anything, ask these questions. They change the design significantly:

  • Scale: How many URLs shortened per day? How many redirects per day? (Write-to-read ratio matters.)
  • Custom aliases: Can users choose their own short code (e.g., tinyurl.com/my-blog)?
  • Expiration: Do URLs expire? Can users set a TTL?
  • Analytics: Do we need click counts, geographic data, referrer tracking?
  • Availability vs. consistency: Is it okay to occasionally serve a stale redirect?

Assume for this design: 100M new URLs/day, 10B redirects/day (100:1 read/write ratio), custom aliases supported, URLs expire after 1 year by default, basic click analytics required.

Step 2: Back-of-Envelope Estimates

Writes:  100M URLs/day  = ~1,200 writes/sec
Reads:   10B redirects/day = ~115,000 reads/sec

URL storage:
  - Short code: 7 chars = 7 bytes
  - Long URL: avg 100 bytes
  - Metadata (created_at, expires_at, user_id, clicks): ~50 bytes
  - Per URL: ~160 bytes
  - 100M URLs/day × 365 days × 5 years = 180B URLs
  - 180B × 160 bytes = ~29 TB total storage over 5 years

Short code length:
  - 7 chars from [a-zA-Z0-9] = 62^7 ≈ 3.5 trillion combinations
  - More than enough for 180B URLs

Step 3: Core API

POST /urls
  Request:  { "long_url": "https://...", "alias": "optional", "ttl_days": 365 }
  Response: { "short_url": "https://short.ly/aB3kR9x" }

GET /{short_code}
  Response: HTTP 301/302 redirect to long_url

GET /urls/{short_code}/stats
  Response: { "clicks": 1234, "created_at": "...", "top_countries": [...] }

301 vs 302 redirect: 301 (Permanent) means browsers cache it — the redirect happens client-side on subsequent visits, reducing server load. But you can’t track repeat clicks or update the destination. 302 (Temporary) hits your server every time — more load but accurate analytics. For a URL shortener with analytics, use 302.

Step 4: Short Code Generation

This is the core algorithmic question. Three approaches:

Option A: Hash the Long URL

import hashlib, base64

def shorten(long_url):
    hash_bytes = hashlib.md5(long_url.encode()).digest()
    # Take first 7 chars of base62-encoded hash
    short_code = base62_encode(hash_bytes)[:7]
    return short_code

Problem: Hash collisions. Two different long URLs may produce the same short code. Must check the DB and append a suffix if collision detected. Also, the same URL always produces the same code — if two users shorten the same URL, they get the same short link (may or may not be desired).

Option B: Auto-Increment ID → Base62

Use the database’s auto-increment primary key and convert it to base62.

def id_to_short_code(id):
    chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    result = []
    while id > 0:
        result.append(chars[id % 62])
        id //= 62
    return ''.join(reversed(result))

# ID 1       → "1"
# ID 1000000 → "4c92"
# ID 3.5T    → 7 chars

Pros: No collisions. Simple, deterministic.

Cons: Sequential IDs are guessable — competitors can enumerate your URLs. Single DB node generates IDs, which can become a bottleneck. Fix the bottleneck with a distributed ID generator like Twitter’s Snowflake or a pre-generated ID pool.

Option C: Pre-generated Random Codes (Best for scale)

A background service pre-generates random 7-character codes, stores them in a “code pool” table, and marks them used when assigned.

-- Code pool table
CREATE TABLE code_pool (
    code CHAR(7) PRIMARY KEY,
    used BOOLEAN DEFAULT FALSE
);

-- Claim a code (atomic)
UPDATE code_pool SET used = TRUE
WHERE code = (SELECT code FROM code_pool WHERE used = FALSE LIMIT 1)
RETURNING code;

Pros: No collision risk, no sequential guessing, no ID generation bottleneck. Pool can be pre-seeded.

Cons: More moving parts; need to keep the pool stocked. Use a background job to refill when the pool drops below a threshold.

Step 5: Data Model

-- Primary URLs table
CREATE TABLE urls (
    short_code  CHAR(7)      PRIMARY KEY,
    long_url    TEXT         NOT NULL,
    user_id     BIGINT,
    created_at  TIMESTAMP    DEFAULT NOW(),
    expires_at  TIMESTAMP,
    click_count BIGINT       DEFAULT 0
);

-- Click analytics (separate — don't slow down redirects)
CREATE TABLE clicks (
    id          BIGINT       PRIMARY KEY,
    short_code  CHAR(7)      REFERENCES urls(short_code),
    clicked_at  TIMESTAMP    DEFAULT NOW(),
    country     CHAR(2),
    referrer    TEXT,
    user_agent  TEXT
);

Database choice: For the URLs table — any relational DB works at moderate scale. At 100M writes/day, a sharded MySQL (like PlanetScale/Vitess) or DynamoDB with short_code as partition key handles this easily. The clicks table is a time-series write-heavy workload → Cassandra or ClickHouse for analytics queries.

Step 6: Redirect Flow with Caching

Redirects are 100:1 reads. Most short codes will be looked up repeatedly. Cache aggressively.

def redirect(short_code):
    # 1. Check cache first
    long_url = redis.get(f"url:{short_code}")
    if long_url:
        async_record_click(short_code)     # fire-and-forget to Kafka
        return redirect_302(long_url)

    # 2. Cache miss → DB lookup
    row = db.get("SELECT long_url, expires_at FROM urls WHERE short_code = ?", short_code)
    if not row or row.expires_at < now():
        return 404

    # 3. Populate cache (TTL = min(24h, time_until_expiry))
    redis.set(f"url:{short_code}", row.long_url, ex=86400)

    async_record_click(short_code)
    return redirect_302(row.long_url)

Hot URLs (viral links) should be cached at the CDN edge too — if the redirect response itself is cacheable (use 301 + CDN), you eliminate origin server load entirely.

Step 7: Analytics at Scale

Recording a click on every redirect would crush the DB. Use a message queue:

Redirect service → Kafka topic "clicks" → Analytics consumer → ClickHouse / Cassandra

The redirect service publishes a click event asynchronously (non-blocking). Analytics consumers batch-insert into a time-series DB. Users query aggregated stats from a read-optimized analytics store, not the transactional DB.

High-Level Architecture

Client
  ↓
CDN (cache 301 redirects for viral URLs)
  ↓
Load Balancer (L7, route /api vs /)
  ↓                    ↓
URL Shortener API    Redirect Service
(POST /urls)         (GET /{code})
  ↓                    ↓         ↓
Primary DB        Redis Cache   Kafka
(write URLs)      (hot URLs)    (click events)
                               ↓
                        Analytics Consumer
                               ↓
                          ClickHouse

Follow-up Questions

Q: How do you handle custom aliases?
Same table. Check if the alias already exists before inserting. Return an error if it’s taken. Rate-limit custom alias creation to prevent squatting.

Q: How do you handle URL expiration?
Store expires_at. Check it on redirect — if expired, return 404. Run a background job nightly to delete expired rows and return codes to the pool.

Q: How do you prevent abuse (malware URLs, spam)?
Integrate a URL reputation API (Google Safe Browsing) on creation. Block known-bad domains. Rate-limit creation by IP/user. Review flagged URLs manually.

Q: How do you scale to 1M writes/sec?
Shard the URLs table by short_code using consistent hashing. Use pre-generated code pools per shard. Deploy multiple redirect service instances behind a load balancer. Push more redirect traffic to the CDN edge.

Summary

A URL shortener is a read-heavy system (100:1) so caching is the primary scaling lever. The short code generation strategy matters — pre-generated random codes avoid collisions and enumeration attacks. Separate the analytics write path from the redirect path using a message queue. The redirect service is stateless and horizontally scalable behind a load balancer. Use a CDN to absorb viral traffic at the edge. This is a clean, complete design that demonstrates you understand caching, async architecture, and scale estimation.

Related System Design Topics

  • Caching Strategies — the redirect path is entirely cache-driven; this is what makes 115K reads/sec feasible.
  • Message Queues — async click recording via Kafka decouples analytics from the hot redirect path.
  • Consistent Hashing — used to route short codes to the correct DB shard at scale.
  • Database Sharding — how to scale the URLs table past what a single Postgres instance can handle.
  • Load Balancing — the redirect service is stateless and trivially horizontally scalable.
  • SQL vs NoSQL — the decision framework for choosing between Postgres, DynamoDB, and Cassandra for different parts of this system.

Also see: Design Twitter Feed, Design a Chat System, and Design YouTube for more complete system design walkthroughs.

Companies That Ask This System Design Question

This problem type commonly appears in interviews at:

See our company interview guides for full interview process, compensation, and preparation tips.

Scroll to Top