Requirements and Capacity Estimation
Every system design begins with requirements. The goal is not to build the largest system possible, but the simplest system that satisfies the stated requirements. Capacity estimation calibrates the design: 1K RPS needs a different architecture than 1M RPS, and building for 1M when you have 1K is waste.
The system: a URL shortener
We will design a URL shortener: a service that maps a long URL to a short alias and redirects clients that request that alias. This is the canonical system design interview problem because it touches almost every topic in this series: databases, caching, CDNs, rate limiting, message queues, and resilience patterns.
Functional requirements
Shorten a URL
Given any valid URL, generate a short alias (e.g. ct.ly/xK3pQ). The alias must be globally unique and URL-safe.
Redirect
GET /:code returns a redirect to the original URL. This is the hot path: every user click hits this endpoint.
Custom aliases
Users may request a specific short code. If available, use it. If taken, return 409 Conflict.
Expiry
URLs expire after a configurable TTL (default: 5 years). Expired codes return 410 Gone, not 404.
Click analytics
Record each redirect: timestamp, referrer, user agent, country. Accessible via dashboard. Best-effort, not transactional.
Non-functional requirements
| Requirement | Target | Notes |
|---|---|---|
| Redirect latency | p99 < 100ms | Cache hit under 5ms. Cache miss under 50ms. This is the user-visible operation. |
| Availability | 99.9% uptime | 8.7 hours downtime per year. The redirect path must have no single point of failure. |
| Durability | Zero URL loss | Once created, a URL must be retrievable until expiry. WAL and synchronous replication required. |
| Write consistency | Immediately readable | A URL created via POST must be immediately redirectable via GET. No eventual consistency on writes. |
| Analytics | Eventually consistent | Click counts may lag by seconds. The redirect path must not wait for analytics writes to complete. |
| Scalability | 10x traffic headroom | Design for 10x peak traffic without re-architecting. Scale out, not up. |
Capacity estimation
Adjust the sliders to match your expected traffic profile. The derived infrastructure requirements tell you what you need to build. The same design decisions apply at every scale; only the sizes change.
Assumptions
Infrastructure
Peak read RPS
2.5x average (traffic burst)
Write RPS
new short URLs created
DB read IOPS
after 90% cache hit rate
Redis memory
top 20% of URLs (hot set)
Total storage
5-yr URL retention
App servers
at 2K RPS capacity each
Worked example: 100M daily redirects
100M/day = 1,157 avg RPS. Peak at 2.5x = 2,894 RPS. At 100:1 read/write ratio: 29 write RPS. With 90% cache hit rate: 289 DB read IOPS. Hot set (top 20% of URLs, 5-year retention): ~183 GB Redis. Total 5-year storage: ~913 GB. Serving capacity needs only 2 app servers, but the hot set no longer fits on a single small Redis instance, budget for a dedicated Redis cluster and a Postgres volume sized for the storage figure, not the request rate.
Push to 1B daily redirects and the picture changes: 28,935 peak RPS, 15 app servers, ~1.8 TB Redis hot set, ~9 TB total storage, read replicas. Same design decisions, different sizing.
Core Design
The URL shortener is, at its core, a key-value lookup: short code maps to long URL. Three decisions define the design: how to generate short codes without collisions, how to store URLs efficiently, and how to serve redirects with sub-100ms latency.
Code generation
The short code must be unique, URL-safe, and short. Base62 (digits + uppercase + lowercase) gives 62 symbols. At 7 characters: 62^7 = 3.5 trillion unique codes. That covers decades of URL creation even at hyperscale.
How: Generate 7 random chars from [0-9A-Za-z]. Check DB for collision; retry if taken.
Tradeoff: Simple. Collision probability at 7 chars: ~1 in 3.5 trillion per generation. Requires one DB read per write to check uniqueness.
How: Use the auto-incremented DB primary key. Base62-encode the integer (e.g. id=12345 -> 3D7).
Tradeoff: Zero collisions. No uniqueness check needed. Sequential IDs are enumerable: anyone can walk your URL space. Mitigate with a random prefix or salted hash.
How: SHA-256 the long URL, take the first 7 chars of the Base62-encoded hash.
Tradeoff: Deterministic: same long URL always maps to the same short code. Enables deduplication. Hash collisions (different URLs, same hash prefix) require a fallback.
Base62 encoding (counter approach)
BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def encode(n: int) -> str:
"""Encode a positive integer as a Base62 string."""
if n == 0:
return BASE62[0]
chars = []
while n:
chars.append(BASE62[n % 62])
n //= 62
return "".join(reversed(chars))
# id=1 -> "1"
# id=62 -> "10"
# id=3844 -> "100"
# id=12345 -> "3D7" (7 chars exhausted at id ~3.5 trillion)
async def create_short_url(long_url: str, user_id: int) -> str:
row = await db.fetchrow(
"INSERT INTO urls (long_url, user_id) VALUES ($1, $2) RETURNING id",
long_url, user_id,
)
short_code = encode(row["id"])
await db.execute(
"UPDATE urls SET short_code = $1 WHERE id = $2",
short_code, row["id"],
)
return short_codeSchema design
PostgreSQL schema
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) NOT NULL UNIQUE,
long_url TEXT NOT NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- NULL means never expires
);
-- Hot path: every redirect does this lookup
CREATE INDEX idx_urls_short_code ON urls (short_code);
-- Dashboard queries: show a user their links
CREATE INDEX idx_urls_user_id ON urls (user_id, created_at DESC);
-- Background job: reap expired URLs
CREATE INDEX idx_urls_expires_at ON urls (expires_at)
WHERE expires_at IS NOT NULL;The redirect API: 301 vs 302
The redirect status code is a product decision, not a technical one. 301 Moved Permanently tells browsers to cache the redirect permanently; future clicks skip your server entirely. 302 Found tells browsers the redirect is temporary; every click hits your server. Most URL shorteners use 302 because it enables click analytics and allows updating where a short code points after creation.
Redirect handler with cache-aside
@router.get("/{short_code}")
async def redirect_url(short_code: str, request: Request, response: Response):
# 1. Check Redis first (cache hit: ~1ms)
long_url = await redis.get(f"url:{short_code}")
if long_url is None:
# 2. Cache miss: query the database
row = await db.fetchrow(
"SELECT long_url, expires_at FROM urls WHERE short_code = $1",
short_code,
)
if row is None:
raise HTTPException(status_code=404)
if row["expires_at"] and row["expires_at"] < datetime.now(UTC):
raise HTTPException(status_code=410) # 410 Gone, not 404
long_url = row["long_url"]
ttl = int((row["expires_at"] - datetime.now(UTC)).total_seconds()) \
if row["expires_at"] else 86400
await redis.setex(f"url:{short_code}", ttl, long_url)
# 3. Fire-and-forget analytics event (never blocks the redirect)
asyncio.create_task(emit_click_event(short_code, request))
# 302: temporary redirect, browsers do not cache
return RedirectResponse(url=long_url, status_code=302)Scaling the Read and Write Paths
The URL shortener is read-heavy by design. At a 100:1 read/write ratio, scaling the redirect path matters far more than scaling the creation path. The strategy is to serve as many redirects as possible without touching the database.
Read path: layered caching
Cache short_code -> long_url with TTL equal to the URL expiry. Cache hit rate above 90% is achievable because traffic is highly skewed: 20% of URLs receive 80% of clicks.
For URLs with very high click volume, serve the 302 redirect directly from CDN edge nodes. The CDN caches the redirect response with a short max-age (60s). Reduces origin traffic by an order of magnitude for viral links.
Route cache misses to a PostgreSQL read replica rather than the primary. Analytics queries (aggregations over click events) go to read replicas and never contend with writes.
Only write operations (new URLs, expiry updates, deletions) go to the primary. With a healthy cache hit rate, the primary receives a small fraction of total traffic.
Cache invalidation
Two events invalidate a cached URL: the user deletes it, or it expires. For deletion, issue a Redis DEL and a CDN cache purge immediately on write. For expiry, rely on the Redis TTL set at insertion time; the key self-expires. Never serve an expired URL: check the database after a cache miss to verify the URL has not expired since it was cached.
Cache invalidation on URL deletion
async def delete_url(short_code: str, user_id: int):
# 1. Delete from DB (authoritative)
deleted = await db.execute(
"DELETE FROM urls WHERE short_code = $1 AND user_id = $2",
short_code, user_id,
)
if not deleted:
raise HTTPException(status_code=404)
# 2. Invalidate Redis immediately
await redis.delete(f"url:{short_code}")
# 3. Purge CDN cache for this path (fire-and-forget)
asyncio.create_task(cdn_purge(f"/{short_code}"))Write path: rate limiting and deduplication
The write path needs two protections: rate limiting to prevent abuse, and deduplication so the same user submitting the same long URL twice gets the same short code back.
Per-user rate limit
Limit URL creation to N per minute per authenticated user. Unauthenticated requests get a lower limit tied to IP. Use a sliding window counter in Redis (see the Rate Limiting article). Return 429 with a Retry-After header when exceeded.
Deduplication
When a user creates a URL, hash the long URL and check for an existing record with the same hash and user_id. Return the existing short code rather than creating a new one. Reduces storage and prevents clutter in user dashboards.
Idempotency key
Accept an optional Idempotency-Key header on POST /urls. Two requests with the same key within a time window return the same response. Prevents duplicate URLs from network retries.
Database: indexes and read replicas
The short_code index is the single most important database optimization. Every cache miss triggers a lookup by short_code. This index must fit in memory (the working set of a PostgreSQL B-tree for 500M URLs with 10-byte codes is roughly 15 GB, well within a single large instance). When the write throughput or query complexity grows, promote one replica to serve read traffic and reserve the primary for writes only.
When to add a read replica
- •Redirect query latency on the primary exceeds 10ms p99
- •Analytics queries are visible in slow query logs
- •CPU on the primary exceeds 60% sustained
When to shard
- •Single-node storage exceeds 5 TB
- •Write throughput exceeds 50K writes/sec
- •A single Redis node no longer fits the hot set in memory
Production: Analytics, Resilience, and What We Left Out
Analytics pipeline
Analytics must never block the redirect path. The solution is to decouple them completely: the redirect handler emits a lightweight event to a message queue and returns the redirect immediately. A separate consumer reads from the queue in batches and writes to an analytical database.
Analytics pipeline: Kafka producer in the redirect handler
# Click event schema (Kafka message value)
@dataclass
class ClickEvent:
short_code: str
timestamp: datetime
referrer: str | None
user_agent: str
ip_hash: str # hashed, not raw IP (privacy)
country: str # resolved from IP at edge
async def emit_click_event(short_code: str, request: Request):
event = ClickEvent(
short_code=short_code,
timestamp=datetime.now(UTC),
referrer=request.headers.get("Referer"),
user_agent=request.headers.get("User-Agent", ""),
ip_hash=hash_ip(request.client.host),
country=request.headers.get("CF-IPCountry", "XX"), # CDN-injected
)
try:
# Fire and forget, if Kafka is down, drop the event
# Analytics loss is acceptable; redirect failure is not
await kafka_producer.send("click_events", value=asdict(event))
except Exception:
pass # never propagate analytics failures to the caller
# ClickHouse consumer (separate service):
# Reads batches from Kafka, inserts into click_events table
# Materialised view aggregates: clicks_by_day, clicks_by_countryWhy Kafka, not a direct DB write?
Writing to ClickHouse (or any database) on every redirect is a synchronous operation that adds latency. Kafka absorbs the burst, batches the writes, and decouples the redirect path from analytics availability.
Why ClickHouse, not Postgres?
ClickHouse is a columnar analytical database optimized for aggregation queries over billions of rows. Queries like clicks by country over 30 days complete in milliseconds. The same query on Postgres would take minutes at this volume.
Resilience
The redirect path has two dependencies: Redis and PostgreSQL. Both can fail. The resilience strategy is to wrap Redis in a circuit breaker with a database fallback, so a Redis outage degrades performance (higher DB load) without causing user-visible failures.
Circuit breaker on Redis with DB fallback
from circuitbreaker import circuit, CircuitBreakerOpen
@circuit(failure_threshold=5, recovery_timeout=30)
async def cache_get(key: str) -> str | None:
return await redis.get(key)
async def resolve_url(short_code: str) -> str:
long_url = None
try:
long_url = await cache_get(f"url:{short_code}")
except CircuitBreakerOpen:
# Redis is known-failing, go straight to DB
# Alert fires: this is a degraded but operational state
pass
except Exception:
# Transient Redis error, circuit will count this failure
pass
if long_url is None:
row = await db.fetchrow(
"SELECT long_url FROM urls WHERE short_code = $1 AND "
"(expires_at IS NULL OR expires_at > NOW())",
short_code,
)
if row is None:
raise HTTPException(status_code=404)
long_url = row["long_url"]
# Attempt to repopulate cache, but do not fail if Redis is down
try:
await redis.setex(f"url:{short_code}", 3600, long_url)
except Exception:
pass
return long_urlThe interview framework
The system design interview is a 45-minute structured conversation, not a free-form whiteboard session. The framework below allocates time to avoid spending 30 minutes on requirements and running out of time before the interesting design problems.
1. Clarify requirements (5 min)
Ask: functional requirements, scale (DAU, RPS), consistency model, latency budget, any non-negotiables. Do not start designing until you know what you are building.
2. Estimate capacity (5 min)
Work through read RPS, write RPS, storage per year, cache memory. Round aggressively. The goal is to identify the bottleneck before designing, not to be precise.
3. High-level design (10 min)
Name the components (client, CDN, app server, cache, database, queue). Sketch the data flow. Define the most important API contracts. Identify the hot path.
4. Deep dive (15 min)
Pick the hardest problem and go deep. For a URL shortener: code generation, cache invalidation, or analytics pipeline. Show you understand the tradeoffs.
5. Operational concerns (5 min)
How do you monitor this? What fails first under overload? What did you leave out and why? This shows engineering maturity.
What we left out
A production URL shortener has dozens of additional concerns. Naming them explicitly in an interview demonstrates that you understand the full scope even when you choose to scope the design.
Geographic distribution
A multi-region deployment with latency-based routing could reduce redirect latency to under 20ms globally. Left out because it adds significant operational complexity and is only justified above ~500M daily redirects.
Custom domains
Allowing users to use their own domain (e.g. links.mycompany.com) requires per-tenant TLS certificate provisioning and wildcard DNS. Left out as a V2 feature.
Abuse prevention
Malicious actors use URL shorteners to obscure phishing links. Production systems integrate with Safe Browsing APIs and maintain a blocklist of flagged long URLs.
Link previews
Open Graph metadata, QR code generation, and preview images require additional storage and a headless browser service. Valuable but out of scope for the core design.