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.
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
- Shorten: Given a long URL, generate a short URL (e.g.,
sho.rt/aZ9kLq) - Redirect: Given a short URL, redirect to the original long URL with low latency
- Custom Aliases: Users can optionally request a custom short code (e.g.,
sho.rt/my-launch) - Expiry: Links can have an optional expiration date, after which they 404
- Analytics: Track click count, referrer, geography, and device type per link
- Auth (optional): Registered users can manage and view analytics for their links
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Scale | 100:1 read/write ratio (redirects vastly outnumber creates) |
| Latency | Redirect in < 100ms (p99), ideally < 20ms |
| Availability | 99.99% uptime — a broken redirect breaks every link ever shared |
| Uniqueness | No two long URLs ever collide on the same short code |
| Durability | Short links must resolve correctly for years |
Back-of-Envelope Estimation
Before designing anything, size the problem — this drives every storage and caching decision below.
| Metric | Assumption | Calculation | Result |
|---|---|---|---|
| New URLs/day | 100M shortens/day | 100M / 86,400s | ~1,160 writes/sec (avg) |
| Peak writes | 3x average | 1,160 × 3 | ~3,500 writes/sec |
| Redirects/day | 100:1 read ratio | 100M × 100 | 10B redirects/day |
| Redirect QPS | 10B / 86,400s | — | ~116,000 reads/sec (avg) |
| Peak reads | 3x average | 116,000 × 3 | ~350,000 reads/sec |
| Record size | URL + metadata | ~500 bytes/record | — |
| Storage/day | 100M × 500 bytes | — | ~50 GB/day |
| Storage over 5 years | 50 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 Length | Combinations (62^n) | Capacity |
|---|---|---|
| 4 chars | 62^4 | ~14.7 million |
| 5 chars | 62^5 | ~916 million |
| 6 chars | 62^6 | ~56.8 billion |
| 7 chars | 62^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.
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
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Random hash (MD5/SHA of URL, truncated) | Hash the long URL, take first 6-7 chars | Simple, stateless, same URL can reuse the code | Collisions require detect-and-retry; not time-ordered |
| Auto-increment counter + base62 | Encode a monotonically increasing integer | No collisions by construction, dense address space | Single point of contention if not distributed |
| Distributed ID generator | Each server gets a pre-allocated range or uses Snowflake-style IDs | No coordination needed per request, scales horizontally | Slightly 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:
- 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. - 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 Code | Browser Caching | Analytics Impact | When to Use |
|---|---|---|---|
| 301 (Permanent) | Browser caches the redirect and skips your server on repeat visits | You lose click data on repeat visits — the browser never asks again | Static, permanent redirects where analytics don't matter |
| 302 (Found/Temporary) | Browser re-requests your server every time | Every click is observable and countable | Default 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.
-- 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.
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 Aspect | Choice | Rationale |
|---|---|---|
| Cache store | Redis (in-memory KV) | Sub-millisecond reads at the scale of 350K QPS peak |
| Eviction policy | LRU | Long-tail cold links naturally age out |
| TTL | 24 hours | Balances freshness (expiry edits) against cache hit rate |
| Cache-aside pattern | Read-through on miss, write on populate | Keeps Redis as a pure cache, not source of truth |
| Negative caching | Cache "not found" for expired/invalid codes briefly | Prevents 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 Stage | Purpose |
|---|---|
| Kafka topic (click-events) | Decouples redirect latency from analytics processing |
| Aggregator consumer | Batches per-link click counts, flushes every few seconds |
| Raw event consumer | Writes full event detail (referrer, geo, device) to an analytics store for dashboards |
| Backpressure handling | If consumers fall behind, Kafka buffers — redirects are never blocked |
Scaling Challenges & Solutions
| Challenge | Solution |
|---|---|
| Single-point ID generation bottleneck | Range-based ID allocation or Snowflake-style distributed generation |
| Hash collisions on random codes | Avoided entirely by using counter-based IDs instead of hashing |
| Hot link click storms | Redis cache absorbs reads; async queue absorbs write pressure |
| Redirect latency under load | Cache-first reads, geographically distributed read replicas |
| Analytics write contention | Separate append-only click store, async batched aggregation |
| Custom alias collisions | Unique index check on write path (acceptable since custom aliases are rare) |
| Expired/malicious link cleanup | TTL-based background sweeper job, negative caching for dead codes |
| Storage growth over years | NoSQL horizontal partitioning by short_code hash; archive cold links |
Key Takeaways
- Size the problem first: The 100:1 read/write ratio should drive every downstream decision toward read optimization.
- Counter-based IDs beat hashing: Deterministic, collision-free generation is simpler than detect-and-retry hashing at scale.
- Distributed ID generation avoids coordination: Range allocation or Snowflake-style IDs let servers mint IDs independently.
- 302, not 301: Permanent-feeling redirects still need 302 status so analytics stay observable.
- 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
- How would you rate-limit link creation to prevent abuse (spam link generation)?
- How would you support link previews (showing the destination before redirecting)?
- How would you handle a "custom alias" namespace running out of short, memorable options?
- How would you geo-distribute the redirect service for global low latency?
- 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.