Design a URL Shortener and Redirect Platform

Published by Programming Line in System Design and Distributed Systems. Last updated .

Purpose and Outcome

A URL shortener turns a long URL into a compact token such as https://sho.rt/aZ91k, then redirects browsers that request that token to the stored destination. In system design interviews and real products, the interesting part is not the string shortening itself. It is how the platform creates unique keys, serves redirects at very low latency, protects users from abuse, records analytics without slowing the redirect path, and survives hot links that receive sudden traffic spikes.

By the end of this lesson, you should be able to design the write path that creates short links, the read path that resolves them, the storage model behind both paths, and the operational choices that keep redirects fast and correct. This connects directly to the system design course because the problem combines API design, key generation, caching, data partitioning, rate limiting, asynchronous processing, and failure isolation in one compact case study.

Core Mechanism

The platform has two primary operations: shorten and redirect. Shorten is a write-heavy control-plane operation compared with redirect, which is usually the dominant data-plane operation. A user submits a destination URL, optional custom alias, optional expiration time, and sometimes campaign metadata. The service validates the destination, chooses or reserves a short code, persists a mapping from code to destination, and returns the public short URL.

Redirect starts when a client requests GET /{code}. The edge or application service extracts the code, finds the mapping, checks whether it is active, and returns an HTTP redirect response. For permanent links, 301 Moved Permanently lets clients and search engines cache aggressively, but it makes later destination changes hard to propagate. For mutable links and analytics-sensitive products, 302 Found or 307 Temporary Redirect is safer because clients should re-check the shortener on future visits. The response includes a Location header containing the destination URL.

The short code is commonly produced by encoding a numeric identifier in Base62, using digits, lowercase letters, and uppercase letters. Base62 keeps tokens URL-friendly and compact: 62 choices per character means six characters can represent more than 56 billion values. Another approach is to generate random tokens and retry on collision. Sequential IDs are simple and collision-free when allocated by one authority, but they reveal volume and can be guessed. Random IDs are harder to enumerate, but require collision detection and enough entropy.

API and Data Anatomy

A minimal creation API might accept POST /v1/links with destination_url, custom_alias, expires_at, and redirect_type. It returns the short URL, code, creation time, and chosen redirect status. The redirect API is intentionally tiny: GET /{code} returns a redirect or an error page. Keeping the redirect contract small makes it easy to cache and to serve from many regions.

CREATE TABLE short_links (
  code TEXT PRIMARY KEY,
  destination_url TEXT NOT NULL,
  owner_id TEXT NOT NULL,
  redirect_status INTEGER NOT NULL DEFAULT 302,
  created_at TIMESTAMP NOT NULL,
  expires_at TIMESTAMP NULL,
  is_disabled BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE click_events (
  event_id TEXT PRIMARY KEY,
  code TEXT NOT NULL,
  occurred_at TIMESTAMP NOT NULL,
  referrer TEXT NULL,
  user_agent TEXT NULL,
  ip_hash TEXT NULL
);

The mapping table belongs on the latency-critical path. The click events table should not. Redirects should enqueue analytics events to a log, queue, or stream after forming the redirect response, or at least with a strict timeout. If analytics storage stalls, the user should still be redirected unless the product has a legal or billing requirement to count every click synchronously.

Worked Example 1: Base62 Codes

The first example shows deterministic code generation from numeric IDs. In practice the numeric ID could come from a database sequence, a Snowflake-style distributed ID, or an ID allocation service that hands out blocks to writers.

ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def base62_encode(number: int) -> str:
    if number < 0:
        raise ValueError("number must be non-negative")
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number:
        number, remainder = divmod(number, 62)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

for value in [0, 61, 62, 3844, 238327]:
    print(value, base62_encode(value))

The expected output is 0 0, 61 Z, 62 10, 3844 100, and 238327 ZZZ. This makes the compactness visible: increasing the identifier from two digits to hundreds of thousands still needs only a few characters. The trade-off is predictability. If a user receives /100, they may try /101. Sensitive links should use random or signed codes instead.

Worked Example 2: Redirect Resolution

The next example models the redirect decision. The service must distinguish a missing code, an expired code, a disabled code, and a valid code because each case drives a different user experience and operational signal.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

@dataclass(frozen=True)
class Link:
    destination_url: str
    redirect_status: int
    expires_at: datetime | None = None
    is_disabled: bool = False

def resolve(code: str, links: dict[str, Link], now: datetime) -> tuple[int, str]:
    link = links.get(code)
    if link is None:
        return 404, "short link not found"
    if link.is_disabled:
        return 410, "short link disabled"
    if link.expires_at is not None and now >= link.expires_at:
        return 410, "short link expired"
    return link.redirect_status, link.destination_url

now = datetime(2026, 9, 6, tzinfo=timezone.utc)
links = {
    "aZ91k": Link("https://example.com/report", 302),
    "old": Link("https://example.com/archive", 302, now - timedelta(seconds=1)),
}
print(resolve("aZ91k", links, now))
print(resolve("old", links, now))
print(resolve("missing", links, now))

The expected behavior is a 302 with the destination for aZ91k, a 410 for the expired link, and a 404 for an unknown code. Returning 410 Gone for known but inactive links helps operators separate abuse takedowns or expirations from mistyped URLs.

Worked Example 3: Cache-Aside Lookup

A redirect platform normally caches active mappings because reads dominate writes. Cache-aside is a common pattern: read from cache first, fall back to the database on miss, then populate the cache with a bounded TTL.

def get_destination(code, cache, database):
    cached = cache.get(code)
    if cached is not None:
        return "cache", cached

    row = database.get(code)
    if row is None or row.get("is_disabled"):
        return "miss", None

    cache = row["destination_url"]
    return "database", row["destination_url"]

cache = {}
database = {"x7": {"destination_url": "https://example.com/pricing", "is_disabled": False}}
print(get_destination("x7", cache, database))
print(get_destination("x7", cache, database))
print(get_destination("bad", cache, database))

The expected output is a database hit first, then a cache hit, then a miss. The missing detail in this toy example is invalidation. If owners can edit destinations or disable links, the production system must delete or update cached entries immediately, use short TTLs, or version mappings so stale entries are rejected.

Design Choices and Trade-offs

Code generation: A single database sequence plus Base62 is simple and avoids collisions, but centralizes ID allocation. Preallocated ID blocks reduce coordination while keeping uniqueness. Random tokens avoid easy enumeration, but require checking whether the generated code already exists. Custom aliases improve usability, but need reservation rules, profanity and trademark policies, and protection against popular namespace squatting.

Storage: A relational database works well for moderate scale because the primary lookup is by exact code. At very high read volume, a distributed key-value store or globally replicated cache can serve redirects closer to users. The link mapping is small, but it is business-critical. Click events are large, append-only, and analytically shaped, so they belong in a stream, warehouse, or columnar analytics store rather than the redirect lookup table.

Regional architecture: The redirect path benefits from edge routing and regional caches. The create path can be centralized or regionally active-active depending on availability goals. Active-active creation requires globally unique IDs, conflict handling for custom aliases, and consistent disable propagation for abusive links. A common compromise is regional redirect serving with a primary write region and replicated read stores.

Failure Modes and Troubleshooting

Symptom: intermittent 404s for newly created links. The likely cause is replication lag between the write store and regional read store. Diagnose by checking the creation timestamp, writer region, reader region, and replication delay metrics. Correct it by reading newly created links from the primary for a short window, using synchronous replication for the mapping table, or routing the first redirect to the writer region until replicas catch up.

Symptom: disabled malicious links still redirect for some users. The likely cause is stale cache entries or client-side caching from a permanent redirect. Diagnose by checking cache TTLs, invalidation logs, and whether the link used 301. Correct it by using temporary redirects for mutable links, purging edge caches on disable, and keeping deny-list checks outside long-lived cached mapping values.

Symptom: redirect latency spikes when analytics storage is slow. The cause is usually synchronous click logging in the request path. Diagnose by comparing redirect latency with queue or database write latency. Correct it by emitting analytics asynchronously, sampling non-critical events during overload, and applying a tiny timeout so redirect generation is not blocked by analytics.

Security, Performance, and Reliability

URL shorteners are abuse magnets because they hide destinations. Creation should validate URL schemes, reject internal network destinations to prevent server-side request forgery in preview scanners, scan against malware and phishing feeds, and rate-limit accounts, IPs, and custom alias attempts. Redirect should avoid open redirect surprises in owned domains by making the shortener domain intentionally separate from authentication domains.

Performance depends on keeping the hot path small: parse code, lookup mapping, check active state, return redirect. Avoid rendering heavy pages for normal redirects. Reliability depends on graceful degradation: if analytics, preview generation, or campaign enrichment is down, redirect should continue. If the mapping store is down and the cache has a fresh value, serve it; if both are unavailable, return a small error page and alert on elevated failure rate.

Hands-on Lab

Prerequisites: Python 3.10 or newer and a shell. No external packages are required.

  1. Create a temporary file named shortener_lab.py.
  2. Paste the Base62 encoder and resolve function from the examples into the file.
  3. Add three links: one active, one expired, and one disabled.
  4. Call resolve for each code and print the returned status and message.
  5. Run python shortener_lab.py.

Verification: The active link should print a 302 or 307 with a URL. The expired and disabled links should print 410. A code that is not in the dictionary should print 404. Then change the active link status from 302 to 301 and verify that only the returned status changes, not the destination.

Cleanup: Delete shortener_lab.py. If you added shell aliases or environment variables for testing, remove them as well. No service state should remain because the lab uses only in-memory dictionaries.

Assessment Exercises

  1. Your product allows destination edits after creation. Which redirect status would you choose by default, and what cache invalidation rule must accompany it?
  2. Design a code generation strategy for three active write regions. Explain how it avoids collisions and how it handles custom alias conflicts.
  3. A celebrity posts a short link and traffic jumps by 100 times in one minute. Which components absorb the spike, and which components must be protected from it?
  4. Analytics events are missing during a queue outage, but redirects succeeded. Is that acceptable for an advertising campaign shortener? Explain the product trade-off.
  5. How would you prevent attackers from using the shortener to probe private IP ranges through automated preview generation?

Summary

A URL shortener is a compact example of high-read system design. The core mapping from code to destination is simple, but the surrounding choices determine whether the platform is usable at scale: collision-free or hard-to-guess codes, fast cache-backed redirects, separated analytics ingestion, careful redirect status selection, abuse controls, and clear behavior for expired or disabled links. A strong design keeps the redirect path narrow while moving slower work to asynchronous systems.