Expert Case Studies

Designing a URL Shortener: Base62 Encoding, Redirects, and Analytics at Scale

Design a URL shortener like Bitly or TinyURL. Cover base62 encoding, distributed ID generation, redirect latency, custom aliases, and async click analytics.

August 14, 2026
case studyurl shortenerbitlybase62distributed idcaching

Why Study URL Shorteners?

The URL shortener is the classic system design warm-up question, and for good reason: it's small enough to fully design in 45 minutes, yet it touches almost every core distributed systems concept — encoding schemes, ID generation without coordination, read-heavy caching, and async analytics pipelines. Services like Bitly handle billions of redirects a month with sub-50ms latency, backed by a system that's deceptively simple on the surface.

Approach: Interviewers use this problem to see if you can move past the "obvious" solution (auto-increment ID + base62) and reason about collision handling, distributed ID generation, and the read/write asymmetry (redirects vastly outnumber creates). Don't rush past the estimation math — it's where this problem earns its difficulty.


Requirements Analysis

Functional Requirements

  1. Shorten: Given a long URL, generate a short URL (e.g., sho.rt/aZ9kLq)
  2. Redirect: Given a short URL, redirect to the original long URL with low latency
  3. Custom Aliases: Users can optionally request a custom short code (e.g., sho.rt/my-launch)
  4. Expiry: Links can have an optional expiration date, after which they 404
  5. Analytics: Track click count, referrer, geography, and device type per link
  6. Auth (optional): Registered users can manage and view analytics for their links

Non-Functional Requirements

RequirementTarget
Scale100:1 read/write ratio (redirects vastly outnumber creates)
LatencyRedirect in < 100ms (p99), ideally < 20ms
Availability99.99% uptime — a broken redirect breaks every link ever shared
UniquenessNo two long URLs ever collide on the same short code
DurabilityShort links must resolve correctly for years

Back-of-Envelope Estimation

Before designing anything, size the problem — this drives every storage and caching decision below.

MetricAssumptionCalculationResult
New URLs/day100M shortens/day100M / 86,400s~1,160 writes/sec (avg)
Peak writes3x average1,160 × 3~3,500 writes/sec
Redirects/day100:1 read ratio100M × 10010B redirects/day
Redirect QPS10B / 86,400s~116,000 reads/sec (avg)
Peak reads3x average116,000 × 3~350,000 reads/sec
Record sizeURL + metadata~500 bytes/record
Storage/day100M × 500 bytes~50 GB/day
Storage over 5 years50 GB × 365 × 5~91 TB
💡

The read/write ratio is the whole design. With reads outnumbering writes 100:1, the system should be optimized almost entirely around fast, cache-friendly redirects. A slow shorten endpoint is tolerable; a slow redirect is not.


High-Level Architecture


Deep Dive: Encoding the Short Code

Base62 Encoding

A short code needs an alphabet that's URL-safe and dense. Base62 uses [a-zA-Z0-9] — 62 characters — avoiding special characters that need escaping in URLs.

The core question: how many characters do we need to represent N unique URLs?

Code LengthCombinations (62^n)Capacity
4 chars62^4~14.7 million
5 chars62^5~916 million
6 chars62^6~56.8 billion
7 chars62^7~3.5 trillion

Since we estimated ~91 TB / 500 bytes ≈ 180 billion URLs over 5 years (accounting for growth beyond the initial estimate), 7 characters comfortably covers decades of growth, while 6 characters covers tens of billions — enough for most real deployments. Bitly and TinyURL both use 6-7 character codes in practice.

text
encode(id):
    digits = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    if id == 0: return digits[0]
    result = []
    while id > 0:
        result.append(digits[id % 62])
        id = id // 62
    return "".join(reversed(result))

This is a pure function: given a numeric ID, it deterministically produces a short code, and the reverse (decode) map is trivial. This is why ID generation, not encoding, is the hard part of this problem.

Counter-Based vs Random Generation

ApproachHow It WorksProsCons
Random hash (MD5/SHA of URL, truncated)Hash the long URL, take first 6-7 charsSimple, stateless, same URL can reuse the codeCollisions require detect-and-retry; not time-ordered
Auto-increment counter + base62Encode a monotonically increasing integerNo collisions by construction, dense address spaceSingle point of contention if not distributed
Distributed ID generatorEach server gets a pre-allocated range or uses Snowflake-style IDsNo coordination needed per request, scales horizontallySlightly more infrastructure
💡

Why not just hash the URL? Hashing (e.g., MD5 then take 6 chars) is tempting because it's stateless, but truncated hashes collide surprisingly often at scale (birthday paradox math kicks in well before a billion entries). Every write then needs a "check DB, if collision retry with a salt" loop — extra read-before-write latency on the hot write path. A counter-based approach sidesteps collisions entirely by construction.

Distributed Unique ID Generation

A single auto-increment counter in one database is a single point of failure and a write bottleneck. Two standard fixes:

  1. Range-based allocation: A lightweight coordination service (backed by a DB row with an atomic increment) hands each application server a block of IDs — e.g., server A gets range [1, 1_000_000), server B gets [1_000_000, 2_000_000). Each server then hands out IDs from its local range with zero coordination until the block is exhausted, at which point it requests a new block. This is cheap, simple, and works well for a service like this where perfect time-ordering isn't required.
  2. Snowflake-style IDs: Each server embeds a timestamp, a worker ID, and a per-millisecond sequence number into a 64-bit ID, generating unique, roughly time-ordered IDs with zero coordination at request time. This guide covers the full bit-layout and clock-skew handling in a dedicated Snowflake ID article — the short version is: it trades a slightly larger ID space for zero coordination overhead per ID, which is the better choice if the service needs to scale past what range allocation comfortably handles.

For a URL shortener specifically, range-based allocation is usually the pragmatic choice — the write volume (thousands/sec) doesn't need Snowflake's millions-per-second throughput, and range allocation produces smaller, denser IDs that encode to shorter base62 strings.


Deep Dive: The Redirect Flow

301 vs 302 — A Real Tradeoff

Status CodeBrowser CachingAnalytics ImpactWhen to Use
301 (Permanent)Browser caches the redirect and skips your server on repeat visitsYou lose click data on repeat visits — the browser never asks againStatic, permanent redirects where analytics don't matter
302 (Found/Temporary)Browser re-requests your server every timeEvery click is observable and countableDefault choice for URL shorteners — you need the click count
⚠️

This is a common interview trap. Candidates default to 301 because it "feels more correct" for a permanent short link. But 301 tells browsers to cache the redirect locally and stop hitting your server — which silently breaks click analytics and defeats one of the product's core features. Nearly every commercial shortener (Bitly, TinyURL) uses 302 specifically to keep every click visible.

Custom Aliases

Custom aliases (e.g., sho.rt/product-launch) skip the ID generator entirely and go through a different write path: check if the requested alias already exists in the DB (a unique index on short_code makes this an O(1) lookup), and reject with a 409 if taken. Because this check must happen synchronously before commit, custom-alias creation is inherently slower than auto-generated codes — but it's a small fraction of total writes, so the extra DB round trip is acceptable.


Database Schema

URLs Table

A key-value access pattern (lookup by short_code, occasional lookup by user_id) makes a wide-column NoSQL store (Cassandra, DynamoDB) a better fit than a relational DB — writes are simple inserts, reads are single-key lookups, and horizontal scaling is native.

sql
-- Modeled here in SQL-like DDL for clarity; in production this maps to a
-- Cassandra/DynamoDB table partitioned by short_code.
CREATE TABLE urls (
    short_code   VARCHAR(10) PRIMARY KEY,
    long_url     VARCHAR(2048) NOT NULL,
    user_id      BIGINT,
    created_at   TIMESTAMP DEFAULT NOW(),
    expires_at   TIMESTAMP,
    click_count  BIGINT DEFAULT 0,
    is_custom    BOOLEAN DEFAULT FALSE
);
 
CREATE INDEX idx_user_id ON urls(user_id);
CREATE INDEX idx_expires_at ON urls(expires_at);

Click Events Table (Analytics Store)

Click events are append-only and high-volume, so they land in a separate, write-optimized store (columnar store like ClickHouse, or a time-series DB) rather than the primary URL table — updating click_count on every single click in the hot path would create write contention on a row that every redirect touches.

sql
CREATE TABLE click_events (
    id          BIGINT PRIMARY KEY,
    short_code  VARCHAR(10) NOT NULL,
    clicked_at  TIMESTAMP NOT NULL,
    referrer    VARCHAR(500),
    country     VARCHAR(2),
    device_type VARCHAR(20),
    ip_hash     VARCHAR(64)
);
 
CREATE INDEX idx_short_code_time ON click_events(short_code, clicked_at DESC);

Why not increment click_count synchronously on every redirect? A hot short link (say, from a viral tweet) can receive thousands of redirects per second, all targeting the same DB row. Incrementing it inline serializes every redirect behind a row lock. Instead, publish a click event to a queue and let an async consumer batch-aggregate counts (e.g., increment every 5 seconds), keeping the redirect path a pure read.


Caching Strategy

Redis for Hot URLs

URL access follows a strong power-law distribution — a small fraction of links (viral posts, marketing campaigns) account for the vast majority of redirects. This makes caching extremely effective:

Cache AspectChoiceRationale
Cache storeRedis (in-memory KV)Sub-millisecond reads at the scale of 350K QPS peak
Eviction policyLRULong-tail cold links naturally age out
TTL24 hoursBalances freshness (expiry edits) against cache hit rate
Cache-aside patternRead-through on miss, write on populateKeeps Redis as a pure cache, not source of truth
Negative cachingCache "not found" for expired/invalid codes brieflyPrevents repeated DB hits from bots probing dead links

Async Analytics Pipeline

Every redirect publishes a click event to a queue (Kafka) rather than writing analytics data inline — this keeps the redirect's critical path down to a cache lookup plus a fire-and-forget publish.

Pipeline StagePurpose
Kafka topic (click-events)Decouples redirect latency from analytics processing
Aggregator consumerBatches per-link click counts, flushes every few seconds
Raw event consumerWrites full event detail (referrer, geo, device) to an analytics store for dashboards
Backpressure handlingIf consumers fall behind, Kafka buffers — redirects are never blocked

Scaling Challenges & Solutions

ChallengeSolution
Single-point ID generation bottleneckRange-based ID allocation or Snowflake-style distributed generation
Hash collisions on random codesAvoided entirely by using counter-based IDs instead of hashing
Hot link click stormsRedis cache absorbs reads; async queue absorbs write pressure
Redirect latency under loadCache-first reads, geographically distributed read replicas
Analytics write contentionSeparate append-only click store, async batched aggregation
Custom alias collisionsUnique index check on write path (acceptable since custom aliases are rare)
Expired/malicious link cleanupTTL-based background sweeper job, negative caching for dead codes
Storage growth over yearsNoSQL horizontal partitioning by short_code hash; archive cold links

Key Takeaways

  1. Size the problem first: The 100:1 read/write ratio should drive every downstream decision toward read optimization.
  2. Counter-based IDs beat hashing: Deterministic, collision-free generation is simpler than detect-and-retry hashing at scale.
  3. Distributed ID generation avoids coordination: Range allocation or Snowflake-style IDs let servers mint IDs independently.
  4. 302, not 301: Permanent-feeling redirects still need 302 status so analytics stay observable.
  5. Cache aggressively, write asynchronously: Redis absorbs the read-heavy hot path; a queue absorbs the analytics write path.

Interview tip: This problem rewards candidates who question the "obvious" design out loud. Say why you're rejecting random hashing (collisions), why 302 beats 301 (analytics), and why click counting is async (write contention on hot rows). The estimation math (base62 capacity, QPS, storage) is also frequently graded explicitly — don't skip it.


Follow-Up Questions to Consider

  1. How would you rate-limit link creation to prevent abuse (spam link generation)?
  2. How would you support link previews (showing the destination before redirecting)?
  3. How would you handle a "custom alias" namespace running out of short, memorable options?
  4. How would you geo-distribute the redirect service for global low latency?
  5. How would you detect and block malicious/phishing URLs at creation time?
💡

Real-world trivia: Bitly, one of the largest URL shorteners, handles tens of billions of redirect requests per year and reports median redirect latency in the low tens of milliseconds — achieved almost entirely through aggressive edge caching. Historically, some shorteners including early versions of Bitly used base36 or custom alphabets before standardizing on base62 (a-z, A-Z, 0-9) as the industry norm for URL-safe density.