Why System Design Matters for Every Engineer
Whether you're building for 100 users or 100 million, the decisions you make about data storage, caching, and service decomposition determine whether your system will survive under load. The URL shortener is the perfect teaching vehicle: simple enough to reason about in an interview, complex enough to surface every major system design challenge.
Step 1: Requirements Gathering
Before drawing any diagrams, clarify requirements:
Functional Requirements
- Given a long URL, generate a unique short URL (e.g.,
bit.ly/abc123) - Given a short URL, redirect to the original long URL
- Custom aliases (optional): user can choose their slug
- Link expiration: links expire after a configurable TTL (default: 5 years)
- Analytics: track click count per short URL
Non-Functional Requirements
- 100 million reads/day (1,157 redirects/second average, ~10,000/second peak)
- 10 million writes/day (new short URLs created)
- 99.99% availability — 52 minutes of downtime/year maximum
- Sub-100ms redirect latency at the 99th percentile
- Durability: short URLs must never silently disappear
Step 2: Capacity Estimation
Back-of-envelope math drives architecture decisions:
Read:Write ratio = 10:1
Writes: 10M/day = 115 writes/second
Reads: 100M/day = 1,157 reads/second (~10K peak)
Storage:
Average URL: 200 bytes
Writes/day: 10M × 200B = 2GB/day
5-year retention: 2GB × 365 × 5 = 3.6TB total URL storage
Bandwidth:
Write: 115 RPS × 200B = 23KB/s (trivial)
Read: 1,157 RPS × 500B (response) = 578KB/s averageKey insight: reads dominate by 10:1. The entire architecture should optimize for read performance.
Step 3: Hash Function Design
The core challenge: given any long URL, generate a unique 7-character short code.
Approach 1: MD5 / SHA-256 (truncated)
MD5("https://bipinbaral.com.np/blog/system-design")
= "a9f3d2c1e84b72...56af"
Take first 7 chars = "a9f3d2c"Problem: Collision rate with 7 chars is 1 / 62^7 = 1 in 3.5 trillion. Sounds safe, but with 10M URLs/day, collisions become statistically significant after ~1 billion URLs.
Approach 2: Base62 Counter (Recommended)
Use a globally unique counter. Encode the counter in Base62 (a-zA-Z0-9):
Counter: 1,000,000,000 → Base62 → "15ftgG"
Counter: 1,000,000,001 → Base62 → "15ftgH"Guarantees uniqueness. Predictable. Fast to generate.
Problem: If using a single counter, it's a single point of failure and a write bottleneck.
Approach 3: Distributed Counter (Production)
Use multiple counter ranges assigned to app server "ranges":
Server A: gets IDs 1,000,000 → 2,000,000
Server B: gets IDs 2,000,000 → 3,000,000
Server C: gets IDs 3,000,000 → 4,000,000Each server assigns IDs within its range without coordination. When a server exhausts its range, it requests a new one from the Counter Service (ZooKeeper or a dedicated microservice).
Step 4: Database Design
URL Table Schema (PostgreSQL)
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(8) NOT NULL UNIQUE,
long_url TEXT NOT NULL,
user_id UUID REFERENCES users(id),
custom_alias BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
click_count BIGINT DEFAULT 0
);
CREATE INDEX idx_urls_short_code ON urls (short_code);
CREATE INDEX idx_urls_expires_at ON urls (expires_at)
WHERE expires_at IS NOT NULL;Sharding Strategy
At 3.6TB over 5 years, a single PostgreSQL instance can handle this comfortably. But if we project to 1 billion URLs/day, we need horizontal sharding.
Shard by the first character of short_code:
- Shard 0: a-g (7 chars = ~11% of keyspace)
- Shard 1: h-n
- Shard 2: o-u
- Shard 3: v-z + 0-9
Each shard is an independent PostgreSQL cluster with a primary and two read replicas.
Step 5: Caching Architecture
Given our 10:1 read/write ratio, caching is the most important performance lever.
Redis Cache Layer
┌─────────────────────────────────────────────┐
│ Client │
└─────────────────┬───────────────────────────┘
│ GET /abc123
┌─────────────────▼───────────────────────────┐
│ Load Balancer │
└─────────────────┬───────────────────────────┘
│
┌─────────────────▼───────────────────────────┐
│ Redirect Service (Node.js) │
│ 1. Check Redis cache for "abc123" │
│ 2. HIT → return 301 redirect │
│ 3. MISS → query DB, populate cache, return │
└─────────────────┬───────────────────────────┘
│
┌────────▼────────┐
│ Redis Cluster │ TTL: 24 hours
│ (6 nodes, HA) │ ~50M entries in memory
└────────▲────────┘
│ on cache miss
┌────────▼────────┐
│ PostgreSQL DB │
│ (primary + │
│ read replicas)│
└─────────────────┘Cache Key Design
Key: "url:abc123"
Value: "https://www.example.com/very/long/path?with=params"
TTL: 86400 (24 hours) — refreshed on hitWith 16GB Redis memory and an average URL of 200 bytes:
16GB / 200B = 80 million URLs cached — covering our top 80% of traffic (hot URLs).
Step 6: Redirect Response Type — 301 vs 302
This is a critical decision with significant performance implications:
| Code | Name | Browser Behavior | Analytics |
|---|---|---|---|
| 301 | Moved Permanently | Browser caches redirect permanently. Next visit skips our server entirely. | Click tracking breaks — browser bypasses us |
| 302 | Found (Temporary) | Browser always checks with our server | Full analytics on every click |
Recommendation: Use 302 if you need click analytics. Use 301 only for static redirects you'll never change.
Step 7: High Availability Architecture
DNS
│
┌────────▼────────┐
│ Cloudflare │ ← Global CDN + DDoS protection
└────────┬────────┘
│
┌───────────▼───────────┐
│ AWS ALB │ ← Multi-AZ load balancer
└───┬─────────────┬─────┘
│ │
┌────────▼──┐ ┌──▼────────┐
│ App Server│ │ App Server│ ← Auto-scaling group
│ (us-east) │ │ (us-west) │ Min: 3 instances
└────────────┘ └────────────┘ Max: 50 instancesMulti-region active-active: Write operations go to the nearest region. Read operations served from local Redis cache in each region. Database replication (async) keeps all regions synced within ~100ms.
Step 8: Analytics Pipeline
Click tracking at 1,157 RPS can't write directly to PostgreSQL per click (it becomes a write bottleneck). Instead:
Click event → Kafka topic ("url-clicks") → Kafka Consumer → Batch aggregate → PostgreSQL counter update every 60 secondsThis decouples analytics from the critical redirect path.
Complete Architecture Summary
| Component | Technology | Reason |
|---|---|---|
| Redirect Service | Node.js (Fastify) | I/O heavy, async redirect — ideal for Node |
| Short Code Storage | PostgreSQL + sharding | Relational, ACID, familiar |
| Cache | Redis Cluster | Sub-millisecond reads at scale |
| Counter Service | ZooKeeper | Distributed coordination |
| Analytics | Kafka + PostgreSQL | Decoupled, reliable event streaming |
| CDN | Cloudflare | Cache static assets, protect from DDoS |
| Orchestration | Kubernetes | Auto-scale redirect service pods |
Conclusion
Designing a URL shortener exposes every major system design challenge: hash function design, database sharding, cache invalidation, distributed counters, analytics pipelines, and geographic distribution. The key insight is that reads always dominate — optimize every layer for read throughput, and writes will take care of themselves at this scale.
