system design

System Design Interview: How to Design a URL Shortener like bit.ly

Complete system design walkthrough for a URL shortener like bit.ly. Covers Base62 encoding, database schema, caching with Redis, custom aliases, expiry, analytics at scale, and global distribution.

By Akash Sharma·21 min read
#system design
#system design interview
#url shortener
#scalability
#redis
#database design
#backend
#base62
#caching

To design a URL shortener like bit.ly, you need a service that maps a short 7-character code to an original long URL, handles billions of redirects per day with sub-50ms latency, caches hot URLs in Redis, and records click analytics asynchronously. The core algorithm is Base62 encoding of an auto-incrementing ID — no collisions, no DB checks, scales to 3.5 trillion unique URLs.

This walkthrough covers every layer: requirements, encoding algorithm, database schema, caching, custom aliases, expiry, scaling the redirect service, and analytics tracking — exactly the depth an interviewer expects.

Requirements: Functional and Non-Functional

Before designing anything, clarify scope. In an interview, ask these questions first:

Clarifying questions to ask the interviewer:

  • Do users need accounts, or is shortening anonymous?
  • Do we need custom aliases (e.g., /my-brand-name)?
  • Do shortened URLs expire, or live forever?
  • Do we need click analytics (count, geo, device, referrer)?
  • What is the expected scale — DAU, URLs per day, redirect ratio?
  • Does the system need to be globally distributed?

Functional requirements:

  • Shorten a URL: POST /shorten → returns a short code like xyz1234
  • Redirect: GET /xyz1234 → 301 or 302 redirect to the original URL
  • Custom aliases: allow users to choose their own slug (/my-custom-slug)
  • Expiry: URLs can have an optional expiration date after which they return 410 Gone
  • Analytics: track click count, country, device type, referrer

Non-functional requirements (target scale):

  • 100M new URLs created per day
  • Read-heavy: 100:1 read-to-write ratio → 10 billion redirects per day
  • Redirect latency: < 50ms at p99
  • High availability: 99.99% uptime (redirect service is critical path)
  • Data retention: 5 years

Capacity Estimates

Turn the requirements into concrete numbers:

MetricCalculationResult
Write rate100M / 86,400s~1,200 writes/sec
Read rate10B / 86,400s~115,000 reads/sec
Storage per URL500 bytes (URL + metadata)
Total storage100M × 500B × 365 × 5~91 TB over 5 years
Cache storageTop 20% of 100M URLs × 500B~10 GB (fits in RAM)

The 100:1 read/write ratio is the defining characteristic. The redirect service is the hot path — it must be blazing fast. URL creation is the cold path — it can tolerate slightly higher latency.

Core Algorithm: URL Encoding

The central question in a URL shortener design: how do you generate a short, unique, URL-safe code for each long URL? There are three approaches. Pick the right one.

Option 1: MD5 Hash (Problematic)

python
import hashlib
import base64
 
def shorten_md5(url: str) -> str:
    hash_bytes = hashlib.md5(url.encode()).digest()
    # Take first 7 characters of base64-encoded hash
    return base64.urlsafe_b64encode(hash_bytes)[:7].decode()
 
shorten_md5("https://example.com/very/long/url")  # → "abc1234"

Why this is flawed:

  1. Hash collisions: two different URLs can produce the same 7-character prefix. You must detect and resolve collisions, which adds a DB round-trip on every write.
  2. Deterministic output: the same URL always produces the same code. If two users shorten the same URL, they share analytics — usually undesirable.
  3. Hard to decode: you cannot recover the original URL from the hash; you need a DB lookup in both directions.

MD5 is a reasonable first answer in an interview, but always follow up with why you'd move away from it.

Option 2: Random Code + Uniqueness Check

python
import secrets
import string
 
BASE62_CHARS = string.ascii_letters + string.digits  # 62 characters: a-z A-Z 0-9
 
def generate_code(length: int = 7) -> str:
    return ''.join(secrets.choice(BASE62_CHARS) for _ in range(length))
 
def shorten_random(url: str, db, redis_client) -> str:
    while True:
        code = generate_code()
        # Check Redis first (fast), then DB
        if not redis_client.exists(f"url:{code}") and not db.exists(code):
            db.insert(code, url)
            redis_client.set(f"url:{code}", url, ex=3600)
            return code

62^7 = 3,521,614,606,208 — over 3.5 trillion combinations. Collision probability is astronomically low, but not zero. Under high concurrency (1,200 writes/sec), two workers could generate the same code simultaneously, both pass the existence check, and race to insert — requiring a unique constraint and retry logic.

This approach works but is inelegant. You can do better.

Option 3: Auto-Increment ID + Base62 Encoding (Best Approach)

This is the standard answer for senior-level system design. Generate a globally unique auto-incrementing integer ID, then convert it to Base62. No collisions possible. No DB check needed during generation.

Why Base62? The alphabet a-z (26) + A-Z (26) + 0-9 (10) = 62 URL-safe characters. No special characters, no encoding issues in URLs.

Why 7 characters? 62^7 = 3,521,614,606,208 unique codes. At 100M URLs per day, that is 96 years of capacity. 62^6 = 56 billion, still huge. 7 is the sweet spot.

python
BASE62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
 
def encode_base62(num: int) -> str:
    """Convert an integer to a Base62 string."""
    if num == 0:
        return BASE62[0]
    
    result = []
    while num:
        result.append(BASE62[num % 62])
        num //= 62
    return ''.join(reversed(result))
 
def decode_base62(code: str) -> int:
    """Convert a Base62 string back to an integer."""
    result = 0
    for char in code:
        result = result * 62 + BASE62.index(char)
    return result
 
# Examples
print(encode_base62(1))          # → "b"
print(encode_base62(1000))       # → "qi"
print(encode_base62(1_000_000))  # → "4c92"
print(encode_base62(10_000_000)) # → "FXsk"
print(decode_base62("FXsk"))     # → 10000000

Sequence generation — two options:

Option A — PostgreSQL BIGSERIAL: the database auto-generates the ID on insert. Simple, but the ID is only known after the insert completes, adding a round-trip before you can compute the code.

Option B — Redis atomic counter (recommended for high write throughput):

python
import redis
 
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
def get_next_id() -> int:
    """Atomically increment a global counter. Thread-safe across all servers."""
    return r.incr("global:url_counter")
 
def shorten(original_url: str, user_id: str | None = None) -> str:
    url_id = get_next_id()
    code = encode_base62(url_id)
    
    db.execute(
        """INSERT INTO urls (id, code, original_url, user_id, created_at)
           VALUES (%s, %s, %s, %s, NOW())""",
        (url_id, code, original_url, user_id)
    )
    
    # Pre-warm cache
    r.setex(f"url:{code}", 3600, original_url)
    
    return f"https://short.ly/{code}"

Redis INCR is atomic. Even with 1,200 concurrent writes/sec across dozens of servers, each call gets a unique integer — no coordination needed beyond the Redis call.

Trade-off to surface in the interview: The sequential nature of IDs means codes are predictable (b, c, d, ...). Anyone who spots the pattern can enumerate all URLs. If privacy matters, XOR the ID with a random salt before encoding, or add a random suffix.

Database Schema Design

URL Table Schema

sql
-- Primary URL storage table
CREATE TABLE urls (
    id              BIGSERIAL PRIMARY KEY,
    code            VARCHAR(10)  UNIQUE NOT NULL,
    original_url    TEXT         NOT NULL,
    user_id         BIGINT,
    created_at      TIMESTAMPTZ  DEFAULT NOW(),
    expires_at      TIMESTAMPTZ,            -- NULL means never expires
    is_active       BOOLEAN      DEFAULT TRUE,
    custom_alias    VARCHAR(50)  UNIQUE,    -- NULL if system-generated
    click_count     BIGINT       DEFAULT 0  -- Approximate counter (see analytics)
);
 
-- Index for the hot path: redirect lookup by code
CREATE INDEX idx_urls_code ON urls(code);
 
-- Index for user dashboard: list a user's URLs
CREATE INDEX idx_urls_user_created ON urls(user_id, created_at DESC);
 
-- Index for expiry cleanup job
CREATE INDEX idx_urls_expires ON urls(expires_at)
    WHERE expires_at IS NOT NULL AND is_active = TRUE;
 
-- Click analytics table (separate from hot path)
CREATE TABLE clicks (
    id          BIGSERIAL    PRIMARY KEY,
    url_id      BIGINT       NOT NULL REFERENCES urls(id),
    clicked_at  TIMESTAMPTZ  DEFAULT NOW(),
    ip_address  INET,
    user_agent  TEXT,
    referer     TEXT,
    country     CHAR(2),     -- ISO 3166-1 alpha-2
    device_type VARCHAR(20)  -- 'mobile', 'desktop', 'tablet', 'bot'
);
 
CREATE INDEX idx_clicks_url_time ON clicks(url_id, clicked_at DESC);

SQL vs NoSQL: Which to Choose?

The redirect lookup is a simple key-value pattern: code → original_url. This is a perfect fit for both SQL and NoSQL. The right answer depends on what else the system needs.

Use PostgreSQL (SQL) when:

  • You need ACID guarantees on URL creation (no duplicate codes, no phantom reads)
  • You have complex analytics queries joining clicks to URLs
  • Your team is comfortable with SQL and schema migrations
  • Scale is under 50,000 reads/sec (easily handled with read replicas + connection pooling)

Use Cassandra or DynamoDB (NoSQL) when:

  • You need horizontal write scaling beyond what a single primary can handle
  • You need multi-region active-active writes (Cassandra's strong suit)
  • The access pattern is truly key-value: you never join tables
  • You can tolerate eventual consistency (a recently created URL might not be immediately readable in all regions)

Recommended approach for most interview answers: Start with PostgreSQL. It handles 1,200 writes/sec and 115,000 reads/sec (with caching and read replicas) without breaking a sweat. If the interviewer pushes for "what if you need to scale writes globally," introduce Cassandra or DynamoDB with a partition key of code and a sort key of created_at.

For DynamoDB specifically:

plaintext
Table: urls
  Partition key: code (String)
  Attributes: original_url, user_id, created_at, expires_at, is_active
  TTL attribute: expires_at  ← DynamoDB handles expiry automatically

DynamoDB's built-in TTL is particularly useful — it automatically deletes items past their expiry date without a separate cleanup job.

Caching Layer

Redirects are read-heavy and the access pattern follows a power-law distribution: a small fraction of URLs (viral links, marketing campaigns) generate the vast majority of traffic. This is ideal for caching.

What to Cache

Cache the mapping from short code to original URL in Redis:

python
CACHE_KEY_PREFIX = "url:"
CACHE_TTL_SECONDS = 3600  # 1 hour default
 
async def get_original_url(code: str, db, redis_client) -> str | None:
    # 1. Check Redis first (microseconds)
    cache_key = f"{CACHE_KEY_PREFIX}{code}"
    cached = await redis_client.get(cache_key)
    
    if cached:
        return cached  # Cache hit — done
    
    # 2. Cache miss — check database (milliseconds)
    row = await db.fetchone(
        "SELECT original_url, expires_at, is_active FROM urls WHERE code = $1",
        code
    )
    
    if not row or not row["is_active"]:
        # Cache negative result briefly to prevent DB hammering
        await redis_client.setex(f"{cache_key}:miss", 60, "1")
        return None
    
    if row["expires_at"] and row["expires_at"] < datetime.utcnow():
        return None  # Expired — do not cache
    
    original_url = row["original_url"]
    
    # Determine TTL: use URL expiry if set, otherwise default
    ttl = CACHE_TTL_SECONDS
    if row["expires_at"]:
        remaining = int((row["expires_at"] - datetime.utcnow()).total_seconds())
        ttl = min(ttl, remaining)
    
    await redis_client.setex(cache_key, ttl, original_url)
    return original_url

Cache Key Design

  • Key: url:{code} → original URL string
  • Value: the original URL (plain string, not JSON — keeps memory tight)
  • TTL: 1 hour for most URLs; shorter if the URL has an expiry time

Eviction Policy

Set Redis maxmemory-policy to allkeys-lru. When Redis runs out of memory, it evicts the least recently used keys. Hot URLs stay in cache; cold URLs get evicted naturally.

Cache Hit Ratio Estimates

With 10B redirects/day and top 20% of URLs driving 80% of traffic:

  • Top 20M URLs (of 100M total) × 500 bytes = 10 GB
  • A single Redis node with 16 GB RAM easily fits the entire hot set
  • Expected cache hit ratio: 85–95%
  • DB queries absorbed: only 5–15% of 115,000 reads/sec = 5,750–17,250 queries/sec on the DB

That is well within what a single PostgreSQL primary with read replicas handles.

Cache warming for viral links: If a marketing campaign launches with a known short URL, pre-populate the cache before the campaign goes live. Prevents a cold-start stampede from hitting the DB.

Custom Aliases and Expiry

Custom Aliases

Users often want readable slugs: /sale2026, /oss-project, /resume. The schema already has the custom_alias column. The creation flow needs a uniqueness check:

python
async def shorten_with_alias(
    original_url: str,
    custom_alias: str | None,
    user_id: str,
    expires_at: datetime | None,
    db,
    redis_client
) -> dict:
    
    if custom_alias:
        # Validate: alphanumeric, hyphens, 3–50 chars
        if not re.match(r'^[a-zA-Z0-9\-]{3,50}$', custom_alias):
            raise ValueError("Invalid alias format")
        
        # Check uniqueness — reserved words too
        RESERVED = {"api", "admin", "login", "static", "health", "metrics"}
        if custom_alias in RESERVED:
            raise ValueError("Alias is reserved")
        
        exists = await db.fetchone(
            "SELECT 1 FROM urls WHERE code = $1 OR custom_alias = $1",
            custom_alias
        )
        if exists:
            raise ValueError("Alias already taken")
        
        code = custom_alias
        url_id = None  # Custom aliases don't use the counter
    else:
        url_id = await get_next_id(redis_client)
        code = encode_base62(url_id)
    
    await db.execute(
        """INSERT INTO urls (id, code, original_url, user_id, created_at, expires_at, custom_alias)
           VALUES ($1, $2, $3, $4, NOW(), $5, $6)""",
        url_id, code, original_url, user_id, expires_at, custom_alias
    )
    
    # Cache immediately
    ttl = 3600
    if expires_at:
        ttl = min(ttl, int((expires_at - datetime.utcnow()).total_seconds()))
    await redis_client.setex(f"url:{code}", ttl, original_url)
    
    return {"short_url": f"https://short.ly/{code}", "code": code}

Rate-limit custom alias checks — a malicious user can probe which aliases exist by repeatedly calling the creation endpoint. Apply per-user rate limiting (e.g., 10 alias checks/minute).

Expiry and TTL-Based Cleanup

Two strategies for expired URLs:

Strategy 1: Lazy expiry — check expires_at on every redirect. If expired, return 410. Simple, but expired URLs occupy storage forever.

Strategy 2: Active cleanup job — run a background job (cron or Celery task) that soft-deletes expired rows:

sql
-- Run every hour
UPDATE urls
SET is_active = FALSE
WHERE expires_at < NOW()
  AND is_active = TRUE
  AND expires_at IS NOT NULL;

Use the partial index idx_urls_expires defined in the schema above — it only indexes rows with non-null expires_at where is_active = TRUE, keeping the index small.

For DynamoDB: set expires_at as the TTL attribute. DynamoDB handles deletion automatically within 24–48 hours of expiry.

Scaling the Redirect Service

The redirect service (hot path) handles 115,000 requests/sec. Here is how to scale it.

Read Replicas

The DB read load (after cache misses) hits the urls table with simple SELECT by code. Add PostgreSQL read replicas and route all redirect queries to replicas. Writes (URL creation) go to the primary only.

plaintext
                     ┌── Read Replica 1 ──┐
Redirect Service ────┼── Read Replica 2 ──┤── Primary (writes only)
                     └── Read Replica 3 ──┘

CDN for Static Redirects

For URLs that will never change and never expire (permanent redirects), you can serve redirects from a CDN edge. The CDN caches the 301 response at the PoP closest to the user — no origin hit needed on repeat visits.

Caveat: CDN caching of redirects only works well with 301 (permanent). If you use 302 (temporary, for analytics), the CDN won't cache it and every request reaches your origin. Choose between CDN-cached 301 redirects and accurate per-request analytics — you cannot have both without client-side tracking.

Global Distribution with GeoDNS

A user in Tokyo hitting a server in Virginia adds 150–200ms round-trip latency just from network travel. That blows the 50ms budget immediately.

Solution: deploy redirect service in multiple regions with GeoDNS routing:

plaintext
Tokyo user   → GeoDNS → ap-northeast-1 (Tokyo)   → Local Redis → Local DB replica
London user  → GeoDNS → eu-west-1 (Ireland)      → Local Redis → Local DB replica
New York user → GeoDNS → us-east-1 (Virginia)    → Local Redis → Local DB replica

                                              All replicas sync from global primary

New URL creation always writes to the global primary (single-region). Replication lag to replicas is typically < 100ms — acceptable for most use cases (a newly shortened URL might not be immediately redirectable from all regions for a brief window).

Horizontal Scaling of the Redirect Service

The redirect service is stateless (all state is in Redis and the DB). Scale horizontally behind a load balancer:

plaintext
                ┌── Redirect Server 1 ──┐
User → LB ──── ├── Redirect Server 2 ──┤──── Redis Cluster ──── DB Read Replicas
                └── Redirect Server N ──┘

With each server handling ~5,000 req/sec (a conservative estimate for a simple Redis lookup + redirect), you need roughly 23 servers for 115,000 req/sec. Autoscale on CPU or request rate.

Analytics Tracking

Tracking every click at 115,000 req/sec is a write problem. You cannot write a clicks row on every redirect — that is 10 billion DB writes per day.

Async Writes via Message Queue

Decouple click recording from the redirect response:

python
from fastapi import FastAPI, BackgroundTasks, Request
from fastapi.responses import RedirectResponse
import aiokafka
 
app = FastAPI()
producer = aiokafka.AIOKafkaProducer(bootstrap_servers='kafka:9092')
 
@app.get("/{code}")
async def redirect_url(code: str, request: Request, background_tasks: BackgroundTasks):
    original_url = await get_original_url(code, db, redis_client)
    
    if not original_url:
        raise HTTPException(status_code=404)
    
    # Publish click event asynchronously — do not await
    background_tasks.add_task(
        publish_click_event,
        code=code,
        ip=request.client.host,
        user_agent=request.headers.get("user-agent", ""),
        referer=request.headers.get("referer", ""),
    )
    
    return RedirectResponse(url=original_url, status_code=302)
 
async def publish_click_event(code: str, ip: str, user_agent: str, referer: str):
    event = {
        "code": code,
        "ts": time.time(),
        "ip": ip,
        "ua": user_agent,
        "ref": referer,
    }
    await producer.send_and_wait("click-events", json.dumps(event).encode())

A separate analytics consumer service reads from Kafka, batches events, and writes to the clicks table or a dedicated analytics store (ClickHouse works well for time-series click data).

In-Memory Click Buffer (Simpler Alternative)

For smaller scale or single-region deployments, buffer clicks in memory and flush periodically:

python
import asyncio
from collections import defaultdict
 
click_buffer = defaultdict(int)  # code → count
buffer_lock = asyncio.Lock()
 
async def record_click_buffered(code: str):
    async with buffer_lock:
        click_buffer[code] += 1
 
async def flush_click_counts():
    """Flush buffered counts to DB every 10 seconds."""
    while True:
        await asyncio.sleep(10)
        async with buffer_lock:
            if not click_buffer:
                continue
            batch = dict(click_buffer)
            click_buffer.clear()
        
        # Batch update click counts
        for code, count in batch.items():
            await db.execute(
                "UPDATE urls SET click_count = click_count + $1 WHERE code = $2",
                count, code
            )
 
# Start flush task on app startup
asyncio.create_task(flush_click_counts())

Approximate Counting with HyperLogLog

For counting unique visitors (not total clicks), exact counting requires storing every IP address — expensive at scale. Redis HyperLogLog gives an approximate unique count with < 1% error using only 12 KB of memory per key:

python
async def record_unique_visitor(code: str, ip_address: str, redis_client):
    """Track unique visitor count using HyperLogLog (~12KB per URL)."""
    hll_key = f"hll:visitors:{code}"
    await redis_client.pfadd(hll_key, ip_address)
    # Set 7-day TTL on the HLL key
    await redis_client.expire(hll_key, 7 * 24 * 3600)
 
async def get_unique_visitor_count(code: str, redis_client) -> int:
    """Get approximate unique visitor count."""
    return await redis_client.pfcount(f"hll:visitors:{code}")

The Redirect Endpoint: Full Implementation

python
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import RedirectResponse
from datetime import datetime
import redis.asyncio as aioredis
 
app = FastAPI()
redis_client = aioredis.from_url("redis://localhost")
 
@app.get("/{code}")
async def redirect_url(
    code: str,
    request: Request,
    background_tasks: BackgroundTasks
):
    # Guard: reject obviously invalid codes early
    if not code or len(code) > 50 or not code.replace("-", "").isalnum():
        raise HTTPException(status_code=404)
    
    # 1. Check Redis cache first
    cache_key = f"url:{code}"
    original_url = await redis_client.get(cache_key)
    
    if not original_url:
        # 2. Cache miss — check for negative cache (non-existent URL)
        if await redis_client.exists(f"{cache_key}:miss"):
            raise HTTPException(status_code=404)
        
        # 3. Query DB
        row = await db.fetchone(
            "SELECT original_url, expires_at, is_active FROM urls WHERE code = $1",
            code
        )
        
        if not row or not row["is_active"]:
            await redis_client.setex(f"{cache_key}:miss", 60, "1")
            raise HTTPException(status_code=404)
        
        if row["expires_at"] and row["expires_at"] < datetime.utcnow():
            raise HTTPException(status_code=410, detail="This URL has expired")
        
        original_url = row["original_url"]
        ttl = 3600
        if row["expires_at"]:
            ttl = min(ttl, int((row["expires_at"] - datetime.utcnow()).total_seconds()))
        
        await redis_client.setex(cache_key, ttl, original_url)
    
    # 4. Record click asynchronously (non-blocking)
    background_tasks.add_task(record_click_buffered, code)
    
    # 5. Return redirect
    # 302 = temporary → browser re-requests each time → accurate analytics
    # 301 = permanent → browser caches → faster repeat visits, but analytics miss repeats
    return RedirectResponse(url=original_url, status_code=302)

Complete Architecture Diagram

plaintext
                         ┌─────────────────────────────────┐
                         │           Global Primary DB       │
                         │     PostgreSQL / DynamoDB         │
                         │     (URL creation writes)         │
                         └──────────────┬──────────────────-┘
                                        │ replication
               ┌────────────────────────┼────────────────────────┐
               ▼                        ▼                        ▼
    ┌──────────────────┐   ┌──────────────────┐   ┌──────────────────┐
    │  US-EAST Region  │   │  EU-WEST Region  │   │  AP-NE Region    │
    │                  │   │                  │   │                  │
    │  Redis Cluster   │   │  Redis Cluster   │   │  Redis Cluster   │
    │  DB Read Replica │   │  DB Read Replica │   │  DB Read Replica │
    │  Redirect Svc ×N │   │  Redirect Svc ×N │   │  Redirect Svc ×N │
    └────────┬─────────┘   └────────┬─────────┘   └────────┬─────────┘
             │                      │                       │
             └──────────────────────┼───────────────────────┘

                              GeoDNS / CDN

                                  Client

                         ┌──────────┴──────────┐
                         ▼                     ▼
                   URL Shortener          Analytics Queue
                   Service (write)          (Kafka)
                   ┌──────────┐               │
                   │ Redis    │               ▼
                   │ Counter  │       Analytics Consumer
                   └──────────┘               │

                                       ClickHouse / Analytics DB

FAQ

How does a URL shortener work?

A URL shortener accepts a long URL, generates a short unique code (typically 6–7 characters), stores the code-to-URL mapping in a database, and returns the short URL. When a user visits the short URL, the service looks up the code in a cache (Redis) or database, then issues an HTTP redirect (301 or 302) to the original long URL. The entire lookup and redirect typically takes under 10ms.

What algorithm does bit.ly use to generate short URLs?

bit.ly and most modern URL shorteners use a variant of Base62 encoding on a unique integer ID. The system maintains a globally unique counter (using an atomic database sequence or Redis INCR). Each new URL gets the next counter value, which is then encoded into a 6–7 character string using the Base62 alphabet (a-z, A-Z, 0-9). This guarantees uniqueness without any collision detection. Some implementations XOR the ID with a random salt to make codes less sequential and harder to enumerate.

Should a URL shortener use SQL or NoSQL?

Both work. Use PostgreSQL (SQL) if you need ACID guarantees, complex analytics queries, or your team is comfortable with relational schemas — it handles this scale easily with read replicas. Use Cassandra or DynamoDB (NoSQL) if you need multi-region active-active writes or want built-in TTL expiry (DynamoDB). The core access pattern — lookup by code — is simple key-value, which both handle equally well. Start with PostgreSQL; migrate to NoSQL if you hit write scaling limits.

How do you scale a URL shortener to billions of requests?

Four levers: (1) Redis caching — cache the code-to-URL mapping with a 1-hour TTL; viral URLs stay hot in memory and never hit the database; (2) Horizontal scaling — the redirect service is stateless, so add servers behind a load balancer; (3) Read replicas — route redirect DB queries to read replicas, reserve the primary for writes; (4) GeoDNS — deploy redirect services in multiple regions and route users to the nearest one, reducing network latency. With Redis absorbing 90%+ of reads, the database sees a fraction of total traffic.

How does a URL shortener track click analytics?

Never write to the analytics database synchronously on the redirect path — that adds latency and becomes a bottleneck at scale. Instead: (1) On each redirect, publish a click event to a message queue (Kafka, SQS) asynchronously as a background task; (2) A separate analytics consumer service reads from the queue, enriches the event (geo-lookup from IP, device detection from user-agent), and batch-writes to an analytics store (ClickHouse, BigQuery, or TimescaleDB); (3) For approximate unique visitor counts, use Redis HyperLogLog — it counts unique IPs with < 1% error using only 12 KB of memory per URL.

How do you handle custom aliases in a URL shortener?

Custom aliases replace the system-generated code with a user-chosen slug. On creation: validate format (alphanumeric + hyphens, 3–50 chars), check against a reserved words list (api, admin, health, etc.), and verify uniqueness against both the code and custom_alias columns with a DB uniqueness constraint as the final safety net. Cache the alias in Redis the same way as a system code (url:{alias} → original URL). Rate-limit alias availability checks to prevent enumeration attacks.

What is the difference between 301 and 302 redirects for URL shorteners?

301 Permanent Redirect: The browser caches the redirect. On the second visit, the browser goes directly to the destination without contacting your server. Faster for repeat users, but you lose click analytics for cached visits and you cannot update the redirect later (the cache sticks). Use 301 when performance matters more than analytics accuracy.

302 Temporary Redirect: The browser does not cache the redirect. Every visit goes through your server. Slower for repeat users, but you capture every click for analytics and can update the destination URL at any time. Most URL shorteners (bit.ly, TinyURL) use 302 for this reason. If you use CDN-level caching, be aware CDNs generally do not cache 302 responses, so every request reaches your origin.

Key Takeaways

  • Auto-increment ID + Base62 encoding is the cleanest approach: no collisions, no DB checks, decodes back to ID
  • 7-character Base62 = 62^7 = 3.5 trillion unique codes — enough for 96 years at 100M URLs/day
  • Cache redirects in Redis with LRU eviction: hot URLs stay in RAM, cold URLs evict naturally; target 90%+ hit rate
  • Use 302 for accurate analytics, 301 for browser-cached performance — you cannot have both without client-side JS tracking
  • Record clicks asynchronously via Kafka or in-memory buffer — never block the redirect response on an analytics write
  • Separate read and write paths: redirect service (high scale, stateless) vs creation service (low scale, writes to primary)
  • Custom aliases need reserved-word filtering, rate limiting, and a DB uniqueness constraint as a final safety net
  • Deploy redirect services in multiple regions with GeoDNS; new URL creation writes to a single global primary

URL shortener is a great interview exercise because it teaches hashing, caching, read/write separation, async processing, and global distribution — all in one compact problem.

Related reading: Database Indexing Explained · Redis Caching Explained · Caching Strategies Explained

Enjoyed this article?

Get weekly insights on backend architecture, system design, and Go programming.