URL Shortener: Requirements & High-Level Design
Problem Statement
Design a URL shortener like bit.ly: a service that takes a long URL and generates a shorter alias — a unique short link — and redirects users to the original URL when someone opens it. It should also track link usage, keeping click counts for every short link. The service must handle millions of URLs and high traffic, respond efficiently in near real-time, and keep every mapping persistent.
The next sections derive the design from this statement: its verbs become the functional requirements, its adjectives the non-functional ones, and its nouns the data model.
Introduction
A URL shortener is a service that converts long, unwieldy URLs into compact aliases that redirect to the original destination. Services like bit.ly, TinyURL, and Twitter's t.co handle billions of redirects daily, turning links like https://example.com/products/category/electronics/item?id=12345&ref=campaign into something as short as https://short.ly/a1B2c3.
Behind the simplicity lies a system with interesting engineering constraints. The write path must generate a globally unique short code in milliseconds. The read path must resolve that code back to the original URL and issue an HTTP redirect with sub-10 ms latency — because redirects sit in the critical path of every click. And the analytics path must count billions of clicks without slowing down redirection.
The core engineering challenges are:
- ID Generation — How do you produce short, unique identifiers across a distributed fleet of servers without coordination bottlenecks?
- Encoding — How do you convert numeric IDs into compact, URL-safe strings? The character set and length determine how many URLs your system can represent.
- Read-Heavy Scale — With a 100:1 read-to-write ratio and 100M read requests per day, the vast majority of traffic is redirects. Caching strategy dominates architecture decisions.
- Consistency vs Availability — A newly created short URL must be resolvable globally within seconds. But cross-region replication introduces latency. How do you handle the window where a URL exists in one region but not another?
In this design, we'll build the system incrementally: first the core shortening and redirect flows, then analytics, then the deep dives into ID generation strategies, encoding trade-offs, caching architecture, and scaling via sharding.
LLD Connection: This problem connects to the Concurrent HashMap problem in Low-Level Design, where you implement the core data structure for O(1) key-value lookups. The URL shortener's in-memory cache and Base62 encoding both rely on hash-based access patterns.

Functional Requirements
We extract the core operations by analyzing the verbs in the problem statement:
- "takes a long URL and generates a shorter alias" → CREATE operation
- "redirects users to the original URL" → READ operation
- "track link usage" → UPDATE/INCREMENT operation
Each verb maps to a functional requirement:
FR1 — URL Shortening. Users submit a long URL and receive a unique, shortened alias. The short URL uses a compact format with English letters and digits (Base62: a-z, A-Z, 0-9) to save space and ensure uniqueness. The mapping between short and long URLs is permanent and immutable — once created, a short URL always points to the same destination.
FR2 — URL Redirection. When a visitor accesses a shortened URL, the service resolves the alias to the original URL and issues an HTTP redirect (302 Found) with minimal delay. The redirect must work from any geographic region.
FR3 — Click Analytics. The system tracks how many times each shortened URL is accessed. This counter is eventually consistent: it doesn't need to be exact in real time, and an occasional click event lost or replayed when a component crashes is acceptable, but counts must never be lost in bulk or drift.
Out of Scope
- Authentication and authorization — Who can create URLs or access analytics (handled by a separate auth service)
- URL expiration — TTL-based deletion of old URLs (can be added as a background job later)
- Custom aliases — User-chosen vanity URLs (
short.ly/my-brand) add complexity to uniqueness guarantees - Advanced analytics — Geographic tracking, device types, referrer analysis (beyond basic click count)
- Link preview / unfurling — OpenGraph metadata fetching for social media previews

Non-Functional Requirements
We extract quality constraints from the adjectives in the problem statement:
- "unique" alias → System must guarantee no collisions
- "millions of URLs" + "high traffic" → System must handle large scale
- "efficiently" + "near real-time" → System must respond quickly
- "persistent" → System must not lose data
| Requirement | Target | Rationale |
|---|---|---|
| High Availability | 99.99% uptime | Short URLs are embedded in emails, social posts, printed media — downtime means broken links everywhere |
| Low Latency | < 10 ms p99 for redirects | Redirects sit in the critical path of every click; users perceive any delay as a "broken link" |
| High Durability | Zero data loss | A shortened URL must work forever; losing the mapping means permanently broken links |
| Uniqueness | Zero collisions | Each short code must map to exactly one long URL across all time and all regions |
| Consistency | Newly created URLs resolvable within 5 seconds globally | Acceptable delay between creation and global availability |
| Security | Prevent malicious URL creation | Block phishing, malware, and spam URLs; prevent enumeration attacks on short codes |
Resource Estimation
Traffic Estimation
| Metric | Calculation | Result |
|---|---|---|
| Daily Active Users (DAU) | Given | 100M |
| Write requests per day | Given | 1M URLs shortened/day |
| Read:write ratio | Given | 100:1 |
| Read requests per day | 1M × 100 | 100M redirects/day |
| Write QPS (average) | 1M / 86,400 | ~12 writes/sec |
| Read QPS (average) | 100M / 86,400 | ~1,160 reads/sec |
| Peak read QPS (3× average) | 1,160 × 3 | ~3,500 reads/sec |
Storage Estimation
| Data | Size | Records | Total |
|---|---|---|---|
| URL mapping | ~500 bytes (short code + original URL + metadata) | 1M/day × 365 days × 5 years | 1.825B records |
| Total storage | 500 bytes × 1.825B | ~912 GB (~1 TB) | |
| Analytics record | ~50 bytes (short code + counter + timestamp) | 1 per URL mapping | ~91 GB |
ID Space Estimation
| Encoding | Characters | Length | Capacity | Sufficient? |
|---|---|---|---|---|
| Base62 (6 chars) | a-z, A-Z, 0-9 | 6 | 62⁶ = 56.8 billion | ✓ (30× our 5-year need) |
| Base62 (7 chars) | a-z, A-Z, 0-9 | 7 | 62⁷ = 3.5 trillion | ✓ (far more than needed) |
| Base16 hex (8 chars) | 0-9, a-f | 8 | 16⁸ = 4.3 billion | ✓ (but wastes 2 chars) |
6-character Base62 provides 56.8 billion unique codes — 30× our 5-year requirement of 1.825 billion URLs. This leaves ample headroom for growth.
Bandwidth Estimation
| Operation | Payload | QPS | Bandwidth |
|---|---|---|---|
| Shorten (write) | ~1 KB request | 12/sec | 12 KB/sec (negligible) |
| Redirect (read) | ~500 bytes response | 1,160/sec | 580 KB/sec |
| Analytics increment | ~100 bytes event | 1,160/sec | 116 KB/sec |
The system is read-dominated and storage-modest — classic traits of a caching-friendly architecture.
Data Model
The data model emerges from the nouns in the problem statement:
- "URL" and "alias" → URLMapping entity with
short_urlandoriginal_urlfields - "link usage" and "click counts" → Analytics entity with
click_countfield - "persistent" requirement →
created_attimestamp for durability tracking
Ownership is distributed across services to enable independent scaling. The URL Shortening Service owns URLMapping (write-optimized). The Analytics Service owns Analytics (increment-optimized).
URL Mapping Table
CREATE TABLE url_mappings (
short_code VARCHAR(7) PRIMARY KEY, -- Base62 encoded, 6-7 chars
original_url TEXT NOT NULL, -- Destination URL (up to 2048 chars)
created_at TIMESTAMP DEFAULT NOW(), -- Creation time
creator_ip_hash BYTEA, -- HMAC of the creator's IP, for abuse detection
is_active BOOLEAN DEFAULT TRUE -- Soft-delete: FALSE once deactivated or expired
);
-- Index for reverse lookup (optional: "does this long URL already have a short code?")
CREATE INDEX idx_original_url ON url_mappings(original_url) WHERE is_active = TRUE;The short_code is the primary key — every redirect performs a lookup by this field. Using it as the primary key ensures a single B-tree index serves both write (INSERT) and read (SELECT) paths.
Why is_active instead of DELETE? When malicious URLs are detected, we soft-delete by setting is_active = FALSE. This preserves the audit trail for abuse investigations and prevents the short code from being reassigned (which would break cached redirects).
Analytics Table
CREATE TABLE url_analytics (
short_code VARCHAR(7) PRIMARY KEY REFERENCES url_mappings(short_code),
click_count BIGINT DEFAULT 0,
last_accessed TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);URLMapping and Analytics have a one-to-one relationship, and it is optional: a short URL gets its analytics row with the first flush after its first click, so a link that was never clicked has none.
The analytics table is separated from the mapping table to isolate the high-frequency increment path (every redirect) from the low-frequency write path (URL creation). Counter updates never lock or bloat the mapping rows that every redirect reads, and each table can be tuned for its own workload.

API Endpoints
We derive API endpoints directly from the functional requirements (the verbs identified earlier):
- CREATE →
POST /api/urls/shorten(accepts longUrl, returns shortUrl) - READ →
GET /{shortCode}(returns 302 redirect to original URL) - UPDATE → Handled internally via event-driven architecture (not a public endpoint)
Shorten URL
POST /api/urls/shorten
Request Body:
{
"longUrl": "https://example.com/very/long/path?param=value"
}
Response (201 Created):
{
"shortUrl": "https://short.ly/a1B2c3",
"shortCode": "a1B2c3",
"createdAt": "2026-03-17T10:30:00Z"
}
| Status Code | Meaning |
|---|---|
201 Created | URL successfully shortened |
200 OK | URL already shortened: returns the existing short URL |
400 Bad Request | Invalid URL format |
429 Too Many Requests | Rate limit exceeded |
Redirect
GET /{shortCode}
Response (302 Found):
HTTP/1.1 302 Found
Location: https://example.com/very/long/path?param=value
Cache-Control: no-store
| Status Code | Meaning |
|---|---|
302 Found | Redirect to original URL |
404 Not Found | Short code doesn't exist |
410 Gone | URL was deactivated (malicious content) |
Why 302 instead of 301? A 301 Moved Permanently tells browsers to cache the redirect indefinitely — subsequent visits bypass our server entirely. This breaks analytics (we can't count clicks we never see) and makes URL deactivation impossible (the browser never asks us again, so it never sees our 410 response). A 302 Found with Cache-Control: no-store ensures every click hits our server, enabling accurate counting and immediate deactivation.
Get Analytics (Internal)
GET /api/urls/{shortCode}/analytics
Response:
{
"shortCode": "a1B2c3",
"clickCount": 42583,
"lastAccessed": "2026-03-17T15:22:00Z",
"createdAt": "2026-01-15T08:00:00Z"
}
High Level Design
We build the architecture incrementally, adding components as each functional requirement demands them. This approach ensures every component exists for a reason — we're solving real problems, not drawing boxes for theoretical completeness.
1. URL Shortening (FR1)
Requirement: Users submit a long URL and receive a unique shortened alias.
The simplest architecture: a single service that generates an ID, encodes it as Base62, stores the mapping, and returns the short URL.

ID Generation: Each server is assigned a unique machine prefix (e.g., A, B, C). Within each server, an auto-incrementing sequence number generates IDs. The combination MachineID + SequenceNumber is globally unique without coordination.
Why this approach? Alternative approaches (hash functions, UUIDs, Snowflake IDs) either produce IDs that are too long or require coordination between servers. Machine ID + Sequence gives us:
- Short IDs — we control the length precisely
- No coordination — each server generates IDs independently
- Scalable — add more servers by assigning new machine prefixes
Base62 Encoding: The numeric ID is converted to a 6-character string using the 62 digits 0-9, A-Z, a-z, in that order:
import string
BASE62_CHARS = string.digits + string.ascii_uppercase + string.ascii_lowercase # 0-9, A-Z, a-z: '0' is the zero digit
def base62_encode(num: int) -> str:
"""Encode an integer to a Base62 string."""
if num == 0:
return BASE62_CHARS[0]
result = []
while num > 0:
num, remainder = divmod(num, 62)
result.append(BASE62_CHARS[remainder])
return ''.join(reversed(result))
def base62_decode(s: str) -> int:
"""Decode a Base62 string back to an integer."""
num = 0
for char in s:
num = num * 62 + BASE62_CHARS.index(char)
return num
# Examples:
# base62_encode(123456) → "W7E"
# base62_encode(56800235583) → "zzzzzz" (max 6-char value)With 6 characters, Base62 supports 62⁶ = 56.8 billion unique codes. At 1M URLs/day, that's enough for 155 years before exhaustion — far beyond our 5-year planning horizon.
2. URL Redirection (FR2)
Requirement: Clicking a short URL redirects to the original URL with minimal delay.
Redirects are the dominant traffic pattern — 100× more frequent than URL creation. A naive approach (query the database for every redirect) would require 1,160 QPS to the database. With a caching layer, we can serve the vast majority of redirects from memory.

Read-through caching: When the Redirect Handler receives a request, it asks the cache layer: a read-through cache library in front of Redis. On a cache miss, the library fetches the URL from the database, stores it in Redis, and returns it to the handler (Redis itself cannot query a database). The handler never talks to the database directly — the cache layer is its single read path.
Why read-through instead of cache-aside? With cache-aside, the application must implement cache-miss logic (check cache → miss → query DB → populate cache). With read-through, the cache library handles this transparently. The Redirect Handler's code is simply:
original_url = cache.get(short_code) # Read-through: auto-fetches from DB on miss
if original_url:
return redirect(original_url, status=302)
return not_found(404)
Cache sizing: With 100M reads/day and Zipfian distribution (20% of URLs get 80% of traffic), caching the top ~2M URLs serves about 93% of redirects (sized in the caching deep dive). At 500 bytes per entry: 2M × 500B = 1 GB — easily fits in a single Redis instance.
TTL strategy: Set TTL to 24 hours for most URLs. Short URLs that are accessed frequently will be refreshed by natural reads. URLs that go cold expire from cache automatically, freeing memory for active URLs.
3. Link Analytics (FR3)
Requirement: Track the number of times each shortened URL is accessed.
Analytics must not slow down redirects. The redirect path returns a 302 in < 10 ms. Analytics recording happens asynchronously after the redirect response is sent.

Two-tier counting:
-
Redis HINCRBY — For every click event it reads from the queue, the Analytics Worker runs
HINCRBY clicks:pending {short_code} 1: an atomic O(1) increment in one Redis hash that handles any QPS. A link's live count is its database total plus its pending clicks in Redis. -
Periodic DB flush — Every 60 seconds, a background job moves the pending clicks into the analytics table, exactly once even if it crashes halfway:
from uuid import uuid4
def flush_analytics_to_db():
"""Runs every 60 seconds (one scheduled job). Moves the pending clicks into the database exactly once."""
# Take the pending counters atomically: clicks that arrive from now on start a fresh hash
if redis.exists("clicks:pending"):
redis.rename("clicks:pending", f"clicks:batch:{uuid4()}")
# Apply every batch not applied yet, including one left behind by a crashed run
for batch_key in redis.scan_iter("clicks:batch:*"):
counts = redis.hgetall(batch_key) # {short_code: clicks}
with db.transaction():
# Recording the batch id in the same transaction makes a retry a no-op
first_time = db.execute(
"INSERT INTO applied_batches (batch_id) VALUES (%s) ON CONFLICT DO NOTHING",
(batch_key,),
).rowcount == 1
if first_time:
db.executemany(
"""INSERT INTO url_analytics (short_code, click_count, last_accessed)
VALUES (%s, %s, NOW())
ON CONFLICT (short_code) DO UPDATE
SET click_count = url_analytics.click_count + EXCLUDED.click_count,
last_accessed = NOW()""",
[(code, int(clicks)) for code, clicks in counts.items()],
)
redis.delete(batch_key)Why not increment the DB directly? At 1,160 QPS, direct UPDATE statements would generate 1,160 transactions/sec on the analytics table. With a 60-second flush, all clicks on the same link within that minute collapse into one row update, and the whole minute is written in a single transaction. Because clicks concentrate on popular links, far fewer rows are written than clicks received; the exact saving depends on how many distinct links are clicked each minute. The trade-off: the database total can be up to a minute behind, and a live count adds the pending clicks from Redis.
4. Complete Architecture

The architecture has three independent scaling paths:
| Service | Scaling Strategy | Bottleneck |
|---|---|---|
| URL Shortening | Add servers with new Machine IDs | ID generation throughput (rarely an issue at 12 QPS) |
| Redirect Handler | Horizontal scaling (stateless) + Redis cluster | Cache miss rate → DB read IOPS |
| Analytics Service | Redis sharding + batch write workers | Redis memory for counters |
Key property: The Redirect Handler is stateless — any instance can serve any redirect. This makes horizontal scaling trivial: add more instances behind the load balancer.
5. Sequence Diagrams
Sequence: Create Short URL

Sequence: Redirect (Cache Hit)
