Design a URL Shortener (Bitly)
An end-to-end interview-ready walkthrough — from back-of-envelope math through deep dives on ID generation, caching, analytics, scaling, and abuse. Structured to mirror the arc of a 45-minute system design interview.
Requirements
Before touching a whiteboard, anchor the problem. A URL shortener is deceptively simple — the scope decides whether you build a single service on one box or a multi-region system with a dedicated ID generator. Separate what the system does from how well it must do it.
Functional Requirements
Core business logic & features
- 01.URL ShorteningGiven a long URL, generate a shorter and unique alias of it.
- 02.RedirectionWhen users access a short link, redirect them to the original long URL.
- 03.Custom AliasesUsers should optionally be able to pick a custom alias for their URL.
- 04.Link ExpirationLinks expire after a default timespan. Users can specify custom expiration.
- 05.Click AnalyticsTrack click count, referrer, geo, and device for each short URL.
- 06.Link ManagementAuthenticated users can list, update, and delete their short URLs.
Non-Functional
System constraints
Availability
99.99% uptime — downtime breaks every live link on the internet.
Latency
Redirect in <10ms p99. The redirect is the product experience.
Scale
100M writes/day, 1B reads/day. 10:1 read-to-write ratio.
Unpredictability
Short codes must be non-guessable to resist enumeration attacks.
🎯 Clarifying questions worth asking
These aren't filler. Each one changes the design:
- Is the same long URL shortened to the same code? (dedupe vs every-request-unique — affects write path)
- Do codes expire or live forever? (TTL cleanup job, storage growth)
- How real-time do analytics need to be? (sync counter vs async pipeline)
- Global or single-region? (multi-region introduces ID-gen coordination)
- Are links public? (scanning for phishing/malware becomes required)
In scope vs out of scope
| In Scope | Out of Scope | Why |
|---|---|---|
| Create + redirect | Link preview / title scraping | Nice-to-have, not core to the redirect SLA |
| Click analytics (async) | Real-time dashboards with sub-second freshness | Separate system — streaming OLAP is its own problem |
| TTL-based expiration | Content moderation appeals flow | Product concern, not a distributed-systems one |
| Custom aliases | Branded domains per customer (rebrandly.com) | Enterprise feature, adds DNS + cert management |
| Basic abuse protection | ML-based phishing detection | Usually offloaded to Google Safe Browsing |
💡 Interviewer signal
Candidates who jump straight to "use Redis" lose the first five minutes. Stating constraints explicitly — "redirect is the hot path, analytics can be async, codes are write-once" — sets the frame for every decision that follows.
Back-of-Envelope Estimation
The numbers decide the architecture. A system doing 100 writes/day is a weekend SQLite project. 100M writes/day is a different conversation. Derive every number out loud — interviewers reward the reasoning, not the final digit.
Traffic: QPS
Writes:
100M / day ÷ 86,400 s ≈ 1,160 writes/sec (average)
Peak ≈ 3× average ≈ 3,500 writes/sec (end-of-day traffic bursts)
Reads (10:1 ratio):
1B / day ÷ 86,400 s ≈ 11,600 reads/sec (average)
Peak ≈ 3× average ≈ 35,000 reads/sec (viral link can spike 10×+)
Implication:
→ Read path is 10× the write path. Every design choice must favor reads.
→ Peak bursts are 3–10×. Provision for peak, not average.
Storage
Per-row estimate (relational row):
short_code : 8 bytes (7 base62 chars + null)
long_url : 200 bytes (average; max 2KB cap)
user_id : 8 bytes
created_at : 8 bytes
expires_at : 8 bytes
─────────────────────────────
Raw row ≈ 230 bytes
With indexes + overhead ≈ 500 bytes/row
Daily growth:
100M rows/day × 500 B ≈ 50 GB/day
→ ~18 TB / year
→ ~90 TB over 5 years (without compression, without deletion)
Implication:
→ A single beefy Postgres box holds it for a year. Shard by year 2.
→ Compress long_url with zstd → ~60% saving. Enable TTL-based archival.
Bandwidth
Read path (redirect):
Payload: ~300 bytes (HTTP 301 headers + body + cookies)
11,600 reads/s × 300 B ≈ 3.5 MB/s (avg)
Peak ≈ 35 MB/s ≈ 280 Mbps
Write path (create):
Payload: ~500 bytes (JSON request + response)
1,160 writes/s × 500 B ≈ 580 KB/s
Implication:
→ Well within a single NIC. Not a bottleneck.
→ Egress cost is the real concern at cloud scale (CDN reduces this).
Cache sizing
Not every URL is accessed equally. The applies: 80% of reads hit 20% of URLs. We only need to cache the hot working set, not the entire database.
80/20 rule: 80% of reads hit 20% of URLs.
Hot working set:
20% × 100M new/day × ~30 days typical lifetime ≈ 600M hot codes
Memory per entry:
key (short_code) : 8 B
value (long_url) : 200 B
Redis overhead : ~80 B (hash table, pointers, TTL)
─────────────────────────
≈ 290 B per entry
Total cache:
600M × 290 B ≈ 175 GB
Implication:
→ Single Redis box can't hold it. Shard by hash(short_code) across ~6 nodes
of 32 GB each, or use Redis Cluster.
→ In practice, cache only top-N by recent access. 10–20 GB covers 95%+
of the hit rate.
Short-code length
Base62 = [0-9] + [a-z] + [A-Z] = 62 characters
Combinations:
6 chars → 62⁶ ≈ 56.8 B (56 billion)
7 chars → 62⁷ ≈ 3.5 T (3.5 trillion) ← sweet spot
8 chars → 62⁸ ≈ 218 T
At 100M writes/day = 36.5B/year:
6 chars runs out in ~1.5 years
7 chars gives us ~95 years of headroom
→ Use 7 characters. It's the industry default (bit.ly, t.co, youtu.be).
🧮 The numbers that drive the design
Quick Revision Cheat Sheet
Write QPS (peak): ~3,500/s
Read QPS (peak): ~35,000/s
R/W ratio: 10:1 — reads dominate
Storage (5y): ~90 TB raw, ~35 TB compressed
Hot cache: ~175 GB theoretical, ~20 GB practical
Short-code length: 7 base62 chars = 3.5T codes
Viral spike: Plan for a single URL at 1M QPS
API Design
A four-endpoint surface covers every functional requirement. Define it before the HLD so every box on the diagram has something concrete to serve.
Endpoints
| Method | Path | Purpose | Latency target |
|---|---|---|---|
POST | /api/v1/urls | Create a short URL (optional custom alias, TTL) | < 50ms p99 |
GET | /:shortCode | 301/302 redirect to long URL (hot path) | < 10ms p99 |
GET | /api/v1/urls/:shortCode | Fetch metadata (admin / owner only) | < 50ms p99 |
DELETE | /api/v1/urls/:shortCode | Soft-delete (owner only) | < 100ms p99 |
Create request
POST /api/v1/urls HTTP/1.1
Host: api.short.ly
Authorization: Bearer <jwt>
Content-Type: application/json
Idempotency-Key: 8f4b3c-... (optional, for retry safety)
{
"long_url": "https://example.com/very/long/path?utm=campaign",
"custom_alias": "launch-2026", // optional
"expires_at": "2026-12-31T23:59:59Z", // optional, ISO 8601
"metadata": { "campaign": "q2" } // optional, opaque
}
--- 201 Created ---
{
"short_code": "launch-2026",
"short_url": "https://short.ly/launch-2026",
"long_url": "https://example.com/very/long/path?utm=campaign",
"created_at": "2026-05-11T09:12:00Z",
"expires_at": "2026-12-31T23:59:59Z"
}
--- 409 Conflict --- (custom alias already taken)
{ "error": "ALIAS_TAKEN", "message": "launch-2026 is already in use" }
--- 400 Bad Request --- (invalid URL, reserved alias, URL too long)
{ "error": "INVALID_URL", "message": "long_url must be http(s) and ≤ 2048 chars" }
Redirect (the hot path)
GET /launch-2026 HTTP/1.1
Host: short.ly
--- 301 Moved Permanently --- (or 302, see trade-offs)
Location: https://example.com/very/long/path?utm=campaign
Cache-Control: public, max-age=300
X-Shortener-Code: launch-2026
--- 404 Not Found --- (code doesn't exist)
--- 410 Gone --- (code existed, deleted)
--- 403 Forbidden --- (code expired)
🔑 Why Idempotency-Key on create
Network retries are routine. Without an idempotency key, a retried POST creates two short codes for the same long URL. Servers store the key for 24h and return the original response on replay. Stripe pioneered this pattern and it's now standard.
Status code choices
| Code | When | Why this one |
|---|---|---|
| 301 | Redirect, permanent | Browsers + CDNs cache aggressively → offloads traffic |
| 302 | Redirect, temporary | Forces every click through origin → accurate analytics |
| 404 | Code never existed | Standard miss |
| 410 | Code existed, deleted | Tells crawlers to drop from index (vs 404 'maybe retry') |
| 403 | Code expired | Distinguishes 'gone on purpose' from 'never was' |
| 429 | Rate limited | Abuse / enumeration protection |
💡 The 301 vs 302 trade-off matters
Pick 302 if analytics fidelity is the product (bit.ly enterprise). Pick 301 if throughput and cost matter more (t.co stopped using 301 specifically to keep analytics). Cover both in the trade-offs section — interviewers want to hear you weigh it.
Data Model
The schema is small but every column has a reason. Design for the read pattern first — 90% of queries are a point lookup on short_code.
CREATE TABLE urls (
short_code VARCHAR(10) PRIMARY KEY, -- 7 chars normally; 10 allows custom aliases
long_url TEXT NOT NULL, -- up to 2048 chars (enforce at app layer)
user_id UUID REFERENCES users, -- nullable for anonymous creates
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NULL, -- NULL = never expires
is_custom BOOLEAN NOT NULL DEFAULT false,
is_deleted BOOLEAN NOT NULL DEFAULT false, -- soft delete for GDPR
metadata JSONB NULL -- campaign tags, etc.
);
-- Index for user's link list (secondary access pattern)
CREATE INDEX idx_urls_user_created ON urls (user_id, created_at DESC)
WHERE is_deleted = false;
-- Index for the TTL cleanup job
CREATE INDEX idx_urls_expires ON urls (expires_at)
WHERE expires_at IS NOT NULL AND is_deleted = false;
🔑 Why short_code is the primary key
The read path is SELECT long_url FROM urls WHERE short_code = ?. Making it the PK gives O(1) — no secondary index hop, no row-id indirection. At 35K reads/s, every saved microsecond counts.
Why these columns and not others
| Column | Included | Reasoning |
|---|---|---|
| short_code | ✅ PK | Primary access key — must be the clustered index |
| long_url | ✅ | The whole point of the system |
| user_id | ✅ nullable | Anonymous creates are common; owners can list/delete |
| created_at | ✅ | Audit + cleanup + 'recently created' queries |
| expires_at | ✅ nullable | TTL support; NULL for permanent links |
| is_custom | ✅ | Affects collision strategy — custom must fail-fast on duplicates |
| is_deleted | ✅ | Soft delete — return 410 Gone instead of 404 |
| click_count | ❌ | Kept in analytics store; writing here creates hot rows |
| last_clicked_at | ❌ | Same reason — hot-row problem under viral traffic |
Supporting tables
-- Single-row counter feeding the base62 encoder.
-- Lives in a tiny dedicated DB or is replaced by Redis INCR in prod.
CREATE TABLE id_counter (
id INT PRIMARY KEY DEFAULT 1,
next_id BIGINT NOT NULL,
CHECK (id = 1) -- enforce singleton
);
-- Each app server batch-reserves 1000 IDs at a time → rarely hit.
-- UPDATE id_counter SET next_id = next_id + 1000 RETURNING next_id - 1000;
Stored in ClickHouse / BigQuery, not Postgres.
click_events (
event_id UUID,
short_code String,
clicked_at DateTime64,
ip_hash FixedString(32), -- salted hash, not raw IP (GDPR)
country_code FixedString(2),
user_agent String,
referrer String
)
PARTITION BY toYYYYMM(clicked_at)
ORDER BY (short_code, clicked_at)
→ Columnar storage → aggregations (clicks per code per day) are fast.
→ Writes arrive via Kafka → Flink pipeline, not synchronously.
Why Postgres (initially) and when it stops working
| Stage | Store | When to switch |
|---|---|---|
| 0 – 10M urls | Single Postgres + Redis cache | Works comfortably on a single mid-tier instance |
| 10M – 1B urls | Postgres + read replicas | Reads saturate the primary — add replicas for list queries |
| 1B+ urls | Sharded Postgres / Cassandra / DynamoDB | Single-box storage exceeded, writes queue |
| Multi-region | Cassandra with LOCAL_QUORUM or DynamoDB global tables | When <100ms cross-region write latency matters |
💡 Shard key choice (when sharding)
Shard by hash(short_code), not by user_id or created_at. The read path is a point lookup on short_code — routing has to be a pure function of the code. Sharding by date creates a hot shard for today's writes; sharding by user makes redirects multi-hop.
High-Level Architecture
The architecture splits into three independent paths, each with its own latency target, consistency model, and scaling strategy. This separation is the single most important structural decision — it lets us optimize each path without compromising the others.
Path 1: Write (create a short URL)
When a user submits a long URL, the request flows through an (which authenticates the JWT and enforces rate limits), then reaches the Shortener Service. This service validates the URL, reserves a unique ID from the counter, encodes it to base62, writes the mapping to the database, and populates the cache so the first redirect is fast.
Client
Browser / app
API Gateway
Auth + rate limit
Shortener Service
Validate + generate code
ID Service
Next base62 ID
Primary DB
Postgres — writes
Cache
Redis — pre-warm
The write path targets <50ms p99. It's not the hot path — only ~3.5K requests/sec at peak — so a single Postgres primary handles it comfortably. The key design choice: we populate the cache immediately after the DB write (write-through) so the very first redirect for a newly-created URL hits cache, not the database.
Path 2: Read (redirect — the hot path)
This is where 90% of traffic lives. A user clicks a short URL and needs to be redirected to the original in under 10ms. The design pushes the response as close to the user as possible through multiple cache layers:
- Browser cache — if we return a 301 with
Cache-Control: max-age=300, the browser remembers the redirect and never contacts us again for 5 minutes. On repeat clicks, latency is literally zero. - — Cloudflare/CloudFront PoPs cache the 301 response. First-time clicks from a region hit the CDN, not origin. Covers ~40% of traffic.
- Redis (origin cache) — for CDN misses, the Redirect Service does a single Redis GET. ~95% hit rate for the hot working set. Sub-millisecond.
- DB read replica — the final fallback. Only ~5% of CDN-miss traffic reaches here. The result is cached in Redis for next time.
Client
Browser click
CDN
Edge cache (301)
Redirect Service
Stateless, autoscaled
Redis
code → long_url
Read Replica
Fallback on miss
The Redirect Service itself is stateless — it holds no data, just logic. This means it can be horizontally scaled to any number of instances behind a load balancer. Each instance has a local of all known short codes — this lets it reject bogus codes (typos, enumeration attacks) in nanoseconds without touching Redis or the DB.
Path 3: Analytics (async, non-blocking)
Every redirect emits a click event — but this must never slow down the redirect itself. The Redirect Service produces the event to using a buffered, non-blocking producer. The produce call takes ~0.5ms and never blocks the HTTP response. If Kafka is temporarily down, events buffer in-process and drain when it recovers.
Downstream, consumes from Kafka, aggregates clicks into 1-minute windows, and writes results to both Redis (for real-time dashboard counters) and (for flexible ad-hoc queries like "clicks by country by day").
Redirect Service
Emit event
Kafka
click-events topic
Flink
Window aggregation
ClickHouse
OLAP store
Redis counters
Real-time counts
Component responsibilities
Each box in the diagram has a single, clear job. If you can't describe what a component does in one sentence, it's doing too much.
API Gateway
Terminates TLS, authenticates JWTs, enforces per-IP and per-user rate limits. Routes /* to Redirect Service, /api/* to Shortener Service. Zero business logic.
Shortener Service
Validates long URL (scheme, length, blocklist). Reserves a base62 ID from the ID Service. Writes to primary DB. Populates cache. Stateless → autoscaled on CPU.
ID Service
Centralized counter (Redis INCR). App servers batch-reserve ranges of 1000 IDs to amortize RPCs. Fallback: ZooKeeper-managed reserve ranges.
Redirect Service
The hottest path. Single cache lookup → single 301/302 response. Stateless, runs at edge regions, autoscaled on request rate. Emits click event fire-and-forget.
Redis Cache
short_code → long_url mapping. Cluster mode for sharding. Bloom filter in front to reject bogus codes without DB hit. ~20GB holds the working set.
Primary DB + Replicas
Postgres primary for writes; read replicas for metadata queries and cache-miss fallback. Shard by hash(short_code) when storage exceeds a single box.
Kafka (click events)
At-least-once delivery. Topic partitioned by short_code for ordering per URL. Retention ~7 days (replay window for downstream fixes).
ClickHouse (analytics)
Columnar OLAP store. Flink pre-aggregates rolling counts; raw events land here for flexible queries. Queries by (short_code, date range) are cheap.
Why three separate paths matter
The redirect SLA (<10ms p99) is incompatible with synchronous analytics writes. Even a 2ms Kafka produce plus a 5ms ClickHouse write would blow the budget. More importantly, coupling redirect availability to analytics availability means a Kafka outage takes down all redirects — unacceptable for a service where every live link depends on uptime.
By separating the paths, each gets its own:
- Consistency model — redirect is CP on the code existing; analytics is eventually consistent
- SLO — redirect: 99.99%; create: 99.9%; analytics: 99%
- Scaling axis — redirect scales on request rate; create on write QPS; analytics on event throughput
- Failure isolation — Kafka down? Redirects still work. DB primary down? Redirects still work (cache + replicas).
Total budget: 10ms p99
0.0ms ─ TLS (keep-alive, already established)
0.2ms ─ API Gateway auth check (cached JWKS)
0.3ms ─ Rate limit check (Redis INCR, pipelined)
0.5ms ─ Bloom filter check (in-process, nanoseconds)
1.0ms ─ Redis GET short_code
├─ 95% hit → go to response
└─ 5% miss → +5ms DB read → cache set
3.0ms ─ Kafka produce (async, non-blocking — doesn't add to response time)
3.5ms ─ Return 301 with Location header
───────────────────────────────
Total on cache hit: ~3–4ms ✅
Total on cache miss: ~7–8ms ✅ (still under 10ms)
💡 What to say to the interviewer
"The three-path split lets us pick different consistency and availability targets per path. Redirect is CP-leaning on the code existing but AP on analytics. Create is CP on uniqueness. Analytics is AP eventual — we tolerate replay and minor loss." That single sentence signals senior-level thinking.
Short Code Generation
This is the core algorithm — the single decision that defines the system. There are three serious approaches. Walking through all three out loud, explaining why two fail, and choosing the winner is the strongest signal a candidate gives in this problem.
Option A: Hash of the long URL
The idea is simple: take the long URL, run it through a hash function like MD5 or SHA-256, then take the first 7 characters of the base62-encoded hash as the short code. No coordination needed — the same input always produces the same output.
This sounds elegant, but it breaks down at scale. The problem is . When you truncate a hash to 7 characters, you're mapping an infinite input space into 3.5 trillion outputs. By the birthday paradox, collisions become likely far sooner than you'd expect — at around 2.4 million URLs you already have a 50% chance of at least one collision.
To handle collisions, you need a read-before-write: check if the code exists, and if so, append a counter or re-hash. This doubles your DB operations per create. Worse, custom aliases don't fit this model at all — you can't hash "launch-2026" into itself.
function shortenViaHash(longUrl: string): string {
const hash = md5(longUrl); // 128-bit hash
const encoded = toBase62(hash); // full base62 string
let candidate = encoded.slice(0, 7); // take first 7 chars
// Collision resolution loop — O(n) in worst case
while (await db.exists(candidate)) {
candidate = toBase62(md5(longUrl + counter++)).slice(0, 7);
}
return candidate;
}
// Problem: at 36B codes, ~1% of writes collide → 2 DB ops per create
❌ Why we reject this
Collisions grow with scale. At 100M writes/day, you'll see thousands of collisions daily, each requiring retry loops. The approach also makes codes predictable (attackers can pre-hash popular URLs) and doesn't support custom aliases. Viable for a weekend project, not for production at scale.
Option B: Random generation
Generate a random 7-character base62 string for each create request. This gives you unguessable codes (great for security) and requires no coordination service. But it shares the same fundamental flaw as hashing: you must check for collisions on every write.
At low density (few codes relative to the 3.5T address space), random collisions are rare. But as the space fills, the birthday paradox strikes again. At 10% fill (~350B codes), roughly 10% of random generations collide. Each collision means a wasted DB round-trip and a retry. Under burst traffic, this creates unpredictable latency spikes.
function shortenViaRandom(): string {
let candidate: string;
let attempts = 0;
do {
candidate = generateRandomBase62(7); // e.g. "xK9mQ2p"
attempts++;
} while (await db.exists(candidate) && attempts < MAX_RETRIES);
if (attempts >= MAX_RETRIES) throw new Error("EXHAUSTED_RETRIES");
return candidate;
}
// Unguessable ✅ but collision-check on every write ❌
❌ Why we reject this
Great for security-sensitive IDs (API keys, tokens) where length doesn't matter. But for a URL shortener, the collision-check overhead and unpredictable retry latency make it a poor fit at 100M writes/day. It also wastes address space faster than a sequential approach.
Option C: Counter + Base62 encoding (chosen)
The winning approach: maintain a global counter that increments on every create. Convert the counter value to a string. Since the counter is monotonically increasing, every ID is unique by construction — zero collisions, ever. One DB write per create, no read-before-write, no retry loops.
The trade-off is that sequential counters produce predictable codes (code N+1 is always one increment away from code N). An attacker could enumerate all URLs by iterating. The fix: apply a (or multiply by a large prime mod 62⁷) to the counter before encoding. This scrambles the output so consecutive creates produce visually unrelated codes, while remaining O(1) and collision-free.
How base62 encoding works
Base62 uses 62 URL-safe characters: digits (0–9), lowercase (a–z), and uppercase (A–Z). To encode a number, repeatedly divide by 62 and map the remainder to a character. The result is a compact string that's safe in URLs without percent-encoding.
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function toBase62(num: number): string {
if (num === 0) return "0000000";
let encoded = "";
while (num > 0) {
encoded = ALPHABET[num % 62] + encoded;
num = Math.floor(num / 62);
}
return encoded.padStart(7, "0"); // always 7 chars for consistency
}
// Examples:
toBase62(3_141_592); // → "000DXYh" (small number, padded)
toBase62(1_000_000_000); // → "015ftgG" (1 billion)
toBase62(3_521_614_606_207); // → "zzzzzzz" (max 7-char value)
Why the Feistel shuffle makes it safe
Without shuffling, counter 1000 → "00000G8", counter 1001 → "00000G9". An attacker iterates trivially. The Feistel cipher takes the counter as input and outputs a different number in the same range (0 to 62⁷−1) — a bijection (one-to-one mapping). So counter 1000 might map to 2,847,193,042 and counter 1001 to 891,204,557. The codes look random but are still guaranteed unique.
// A 3-round Feistel network over the range [0, 62^7)
// Guarantees: bijective (no collisions), O(1), reversible
function shuffle(id: number): number {
const MAX = 62 ** 7; // 3,521,614,606,208
let left = Math.floor(id / Math.sqrt(MAX));
let right = id % Math.floor(Math.sqrt(MAX));
for (let round = 0; round < 3; round++) {
const temp = right;
right = left ^ roundFunction(right, ROUND_KEYS[round]);
left = temp;
}
return left * Math.floor(Math.sqrt(MAX)) + right;
}
// Usage in the create path:
const rawId = await idService.next(); // e.g. 3,141,592
const shuffled = shuffle(rawId); // e.g. 2,109,847,331
const shortCode = toBase62(shuffled); // e.g. "k9Qm2Xp" — looks random
✅ Why this wins
Zero collisions (counter is monotonic). One DB write per create (no read-before-write). O(1) computation. Codes are compact (7 chars). Feistel makes them unguessable. Custom aliases use a separate path (direct insert with conflict check). This is what Bitly, TinyURL, and every production shortener uses under the hood.
Scaling the counter service
A single Redis command handles ~3.5K increments/sec easily. But it's a single point of failure. The solution is batched range allocation: each app server reserves a block of 1,000 IDs at once, uses them locally, and only contacts Redis when the local pool runs low.
| Strategy | How it works | Trade-off |
|---|---|---|
| Single Redis INCR | Every app server calls INCR per create request | Simple; Redis handles 3.5K/s easily. But Redis is SPOF. |
| Batched range allocation (chosen) | App server reserves 1000 IDs at once; uses them locally; refills when pool < 100 | ~1000× fewer Redis calls. Lose up to 1000 IDs on crash (acceptable — 0.001% waste). |
| ZooKeeper ranges | ZooKeeper assigns disjoint ranges per app server at startup | Stronger durability than Redis; higher latency; more operational complexity. |
| Snowflake-style IDs | 64-bit ID = timestamp (41 bits) + machine_id (10 bits) + sequence (12 bits). No coordination. | IDs are 64-bit → need 11 base62 chars. Longer codes = worse UX for a URL shortener. |
What is ?
Snowflake is Twitter's distributed ID generator. It packs a timestamp, a machine identifier, and a per-machine sequence counter into a single 64-bit integer. Because each machine has a unique ID (assigned via ZooKeeper), no two machines ever produce the same number — zero coordination at runtime.
The downside for URL shorteners: 64-bit numbers need 11 base62 characters to encode (vs 7 for our counter approach). That's a 57% longer URL — a meaningful UX regression for a product whose entire value is shortness. Snowflake is ideal for internal IDs (database PKs, event IDs) where length doesn't matter.
Custom aliases — a separate path
Custom aliases (like /launch-2026) bypass the counter entirely. They're user-provided strings that go through validation (reserved words, length, regex) and then a conditional insert. If the alias is already taken, return 409 immediately.
if request.custom_alias:
validate_alias(alias) → 400 if reserved/invalid
INSERT ... ON CONFLICT DO NOTHING
if rowcount == 0 → 409 ALIAS_TAKEN
return success
else: (auto-generated)
id = ID_SERVICE.next() → from local pool (batched)
short_code = toBase62(shuffle(id))
INSERT ... → no conflict check needed (counter guarantees uniqueness)
return success
🔒 Reserved word list
At minimum: api, admin, login,signup, static, favicon.ico,robots.txt, every route your service exposes. Otherwise a user creates /api as a custom alias and breaks the API Gateway routing.
Write Path Deep Dive
Creating a short URL is the lower-volume path, but it's where correctness lives — uniqueness guarantees, custom alias conflicts, idempotency, and cache consistency all decide here.
Validate the request
Check scheme (http/https only), length (≤2048), format, and that the host isn't in the internal block-list. Reject SSRF-shaped URLs (localhost, 169.254.169.254, 10.0.0.0/8). A malicious URL here becomes a malicious redirect later.
Check idempotency key
If header present, look up in a short-TTL Redis store. Hit → return the cached response (network retry). Miss → proceed and store the response keyed by the idempotency key after success.
Route: custom alias vs auto-generated
If custom_alias: validate against reserved list, regex, length. Attempt conditional insert. On conflict → 409. If auto: call ID Service for next ID, bit-shuffle, encode base62.
Single-statement insert
INSERT INTO urls (...) ON CONFLICT (short_code) DO NOTHING RETURNING short_code. No read-then-write race. On rowcount=0 for custom path, return 409.
Cache-aside populate
After DB write succeeds, SET short_code → long_url in Redis with TTL matching expires_at (or a long default). This is called — it avoids a cold miss on the first redirect.
Return 201 with the short URL
Build the short URL from the canonical domain (from config, not the request host header — avoid host-header poisoning). Log the create event for audit.
The create handler, roughly
async function createShortUrl(req: CreateRequest): Promise<CreateResponse> {
// 1. Validation
validateUrl(req.long_url); // throws on bad scheme/length
checkSSRFBlocklist(req.long_url); // block internal ranges
// 2. Idempotency
if (req.idempotency_key) {
const cached = await idempotencyStore.get(req.idempotency_key);
if (cached) return cached; // network retry — return same response
}
// 3. Short code
let short_code: string;
if (req.custom_alias) {
validateAlias(req.custom_alias); // reserved words, regex
short_code = req.custom_alias;
} else {
const id = await idService.next(); // batched from pool
short_code = toBase62(shuffle(id)); // Feistel to break predictability
}
// 4. Conditional insert — serves both paths
const result = await db.query(`
INSERT INTO urls (short_code, long_url, user_id, expires_at, is_custom)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (short_code) DO NOTHING
RETURNING short_code, created_at
`, [short_code, req.long_url, req.user_id, req.expires_at, !!req.custom_alias]);
if (result.rowCount === 0) {
if (req.custom_alias) throw new AliasTakenError();
throw new InternalError("counter produced a duplicate — should not happen");
}
// 5. Populate cache (write-through)
const ttl = req.expires_at ? secondsUntil(req.expires_at) : DEFAULT_TTL;
await cache.set(short_code, req.long_url, ttl);
// 6. Build response, persist idempotency entry
const response = buildResponse(short_code, result.rows[0], req);
if (req.idempotency_key) {
await idempotencyStore.set(req.idempotency_key, response, 86_400);
}
return response;
}
🎯 Why ON CONFLICT DO NOTHING beats SELECT-then-INSERT
Two concurrent requests with the same custom alias hit the DB at the same moment. A SELECT-then-INSERT pattern has a — both see "not taken", both INSERT, one gets a unique-constraint error. ON CONFLICT DO NOTHING makes the uniqueness check and the write atomic. The loser gets rowCount === 0 and a clean 409.
Failure modes on the write path
🔥 ID Service unavailable
App server tries to refill its local ID pool and Redis is down.
Mitigation: local pool still has ~500 IDs on-hand from the last refill — you have a ~2-minute runway. If Redis is still down, fall back to a ZooKeeper-managed reserve range per app server.
🔥 DB write succeeds, cache set fails
Redis dropped the connection between the successful INSERT and the cache SET.
Mitigation: don't fail the request. The first redirect will miss and populate the cache lazily. The create response is still correct.
🔥 Duplicate long URL from same user
User creates the same long_url twice — get two different codes.
Mitigation (optional): before creating, check for an existing active code from the same user with the same long_url. Return that instead of creating new. Costs one indexed read per create. Skip if not a product requirement.
Read Path Deep Dive
The redirect is the product. Every microsecond spent here is felt by every user on every click. The whole path is designed to push the response as close to the user — and as early in the stack — as possible.
The cache layers (edge to origin)
| Layer | Hit rate | Effect |
|---|---|---|
| Browser cache (301 only) | ~50% on repeat clicks | Zero server hit; client goes direct to long URL |
| CDN edge cache | ~40% of first clicks | Served from the nearest PoP; single-digit ms |
| Redis (origin) | ~95% of CDN misses | In-memory lookup; ~1ms |
| DB read replica | ~100% of Redis misses | ~5ms; fallback + cache-fill |
🎯 Why the 301 layer matters most
With a 301 Moved Permanently response and Cache-Control: public, max-age=N, browsers skip the server entirely on subsequent clicks. At viral scale (1M+ clicks to one URL), the origin sees 1K requests after the first 10 seconds — the rest are absorbed by browsers and CDN PoPs. This is the single biggest lever for scaling reads.
Redirect flow
CDN edge check
CloudFront / Cloudflare PoP receives the request. If the 301 for this short_code is cached with a live TTL, return immediately. No origin hit.
Bloom filter pre-check (origin)
On CDN miss, the Redirect Service consults an in-process bloom filter of all known short codes. If 'definitely not present' → return 404 immediately. Protects the DB from enumeration scans.
Redis lookup
GET short_code. ~95% hit rate for codes in the hot working set. On hit → build 301 → emit click event fire-and-forget → return.
DB fallback with request coalescing
On cache miss, use to dedupe concurrent misses for the same code. Only one in-flight DB read per code per app server. Populate cache on success. Return 404 if genuinely absent.
Emit click event (async)
Produce to Kafka with short_code as partition key. This call is non-blocking — a buffered producer batches events. Losing an event drops an analytics click, not a user redirect.
async function handleRedirect(short_code: string, req: Request): Promise<Response> {
// 1. Bloom filter pre-check — rejects bogus codes without DB hit
if (!bloom.mightContain(short_code)) {
metrics.bloomReject.inc();
return new Response(null, { status: 404 });
}
// 2. Cache lookup
let long_url = await cache.get(short_code);
if (long_url === null) {
// 3. DB fallback with request coalescing
long_url = await singleflight.do(short_code, async () => {
const row = await readReplica.query(`
SELECT long_url, expires_at, is_deleted
FROM urls WHERE short_code = $1
`, [short_code]);
if (!row) return null;
if (row.is_deleted) return { status: 410 };
if (row.expires_at && row.expires_at < Date.now()) return { status: 403 };
// Populate cache on hit
await cache.set(short_code, row.long_url, CACHE_TTL);
return row.long_url;
});
if (!long_url) return new Response(null, { status: 404 });
}
// 4. Emit click event — non-blocking, fire-and-forget
kafkaProducer.produce("click-events", short_code, {
clicked_at: Date.now(),
ip_hash: hashIp(req.ip),
country: req.geo?.country,
user_agent: req.headers["user-agent"],
referrer: req.headers["referer"],
});
// 5. Respond with 301 + cache headers
return new Response(null, {
status: 301,
headers: {
Location: long_url,
"Cache-Control": "public, max-age=300",
"X-Shortener-Code": short_code,
},
});
}
Defeating the three classic cache failure modes
| Failure | What it is | Mitigation used here |
|---|---|---|
| Cache penetration | Lookups for codes that don't exist hit DB repeatedly | Bloom filter blocks unknowns; cache empty result for 60s |
| Cache stampede | Popular code expires; thousands of misses hit DB at once | Request coalescing (singleflight); jittered TTLs |
| Cache avalanche | Many keys expire simultaneously after a mass invalidation | Randomized TTLs (±20%); multi-tier L1+L2 cache |
💡 Why the bloom filter earns its keep
Without it, a scan like GET /aaaa1, GET /aaaa2... becomes 35K RPS of DB reads that all miss. The bloom filter answers "is this code even plausible?" in nanoseconds using ~1GB of RAM for 1B codes at 1% false positive. False positives fall through to Redis, so correctness is preserved — only the performance floor is raised.
Cache invalidation on delete
1. UPDATE urls SET is_deleted = true WHERE short_code = ? AND user_id = ?
2. DEL short_code (evict from Redis)
3. CDN purge API: POST /purge { path: "/launch-2026" }
4. Update bloom filter tombstone set (bloom itself can't remove; use a
secondary "recently deleted" set checked on bloom-pass)
Gotcha:
Browsers with 301 in their cache will NOT re-check until max-age expires.
This is fundamental to 301 — can't be invalidated remotely.
→ For deletable links, use 302 with a short max-age instead of 301.
Analytics Pipeline
Every click is a telemetry event. The pipeline exists for two reasons: to keep the redirect path free of analytics writes, and to support flexible queries (clicks by country, referrer, hour) that a simple counter can't answer. It's a mini streaming system bolted onto the side.
Redirect Service
Emits event
Kafka
Durable log, 7-day retention
Flink
Windowed aggregation
ClickHouse
OLAP queries
Redis counters
Real-time click counts
Why Kafka instead of synchronous writes
| Option | Latency | Durability | Verdict |
|---|---|---|---|
| Synchronous DB insert on redirect | +10–20ms per click | Strong | ❌ Breaks 10ms p99 SLA |
| Fire-and-forget UDP log | ~0ms | Lossy (10%+ under load) | ❌ Analytics unusable |
| Kafka async producer | ~0.5ms (buffered) | At-least-once | ✅ Best of both |
What Flink actually does
Input stream: click-events (Kafka topic, ~11.6K events/sec)
Job 1 — Per-code rolling count (1-minute tumbling window)
keyBy(short_code)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new CountAggregator())
.addSink(redisSink) // → Redis key: counts:{short_code}:{minute}
Job 2 — Geo breakdown (5-minute window)
keyBy(short_code, country_code)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new CountAggregator())
.addSink(clickhouseSink)
Job 3 — Raw event archive
.addSink(clickhouseSink) // raw events → ClickHouse, partitioned by day
Watermark: event_time - 30s (allows some network reorder)
Checkpoint: every 30s to S3 (exactly-once semantics on restart)
🎯 Why three jobs, not one
Different consumers of analytics have different freshness and shape needs. Dashboard needs 1-minute counters — Job 1. Campaign reports need per-country breakdowns — Job 2. Forensics needs every event — Job 3. Each job scales independently and can be restarted without affecting the others.
Why ClickHouse (and not Postgres)
| Need | Postgres | ClickHouse |
|---|---|---|
| Ingest 10K events/sec | Strains a single node | Built for it — 100K+/sec per node |
| Query: clicks(code, date range) | OK with indexes | 10–100× faster (columnar, skip indexes) |
| Storage cost for 1B rows | ~500 GB | ~50 GB (strong compression) |
| Flexible ad-hoc queries | Yes, but slow at scale | Yes, fast |
| Transactions / updates | ACID | Append-only (fine for events) |
Delivery semantics — be explicit
Every stage in the pipeline has a — the promise about whether events can be lost or duplicated. Being explicit about this per-stage is what separates a hand-wavy answer from a production-ready one.
Redirect Service → Kafka:
acks=1, buffered producer, linger.ms=10
→ at-least-once (producer retries on failure)
→ events may duplicate if app server crashes between produce and ack
Kafka → Flink:
consumer offsets committed after checkpoint
→ exactly-once within Flink pipeline (via two-phase commit)
Flink → ClickHouse:
idempotent insert with (event_id, event_time) dedup key
→ at-least-once writes, deduplicated on read
End-to-end: effectively exactly-once for analytics use cases.
At-most-once is NEVER appropriate here — undercount is unacceptable.
💡 Real-time counter shortcut
For the "X clicks in the last hour" number that shows on the dashboard, don't query ClickHouse on every page load. Have the Flink rolling-count job write to Redis keyed by counts:{code}:{hour}. Dashboard reads are O(1) Redis gets. ClickHouse is reserved for "give me the full breakdown" queries.
Scaling & Reliability
Different traffic levels need different architectures. Naming them explicitly shows you can scale up and down — starting simple and earning complexity is what senior engineers do.
Scaling at different tiers
| Scale | Architecture | Why |
|---|---|---|
| 1M/day | One Postgres, one Redis, two app nodes behind an ALB | Works. Don't overbuild. A t3.medium Postgres handles 12 writes/s. |
| 10M/day | + Read replica for metadata queries + CDN for static | Metadata list queries (user dashboard) start to compete with redirect writes. |
| 100M/day (target) | + Redis Cluster (3 nodes), batched ID allocation, Kafka analytics, CDN 301 caching | Peak redirect QPS needs dedicated cache cluster; analytics must go async. |
| 1B/day | + Postgres sharded by hash(short_code) across 8 shards, multi-region Redis | Single primary hits its write ceiling (~5K writes/s with commits). |
| 10B/day | + Cassandra/DynamoDB as primary, multi-region active-active, anycast DNS | Cross-region latency dominates; geographic traffic steering mandatory. |
Sharding strategy (when it's time)
shard_id = hash(short_code) % N_SHARDS
Routing happens at the app layer (no sharding proxy needed at this scale).
Trade-offs:
✅ Point lookup is single-shard → O(1) routing
✅ Writes distribute evenly — no hot shard from sequential IDs
(the Feistel shuffle on the counter was already essential)
❌ "All URLs for user X" becomes scatter-gather — not critical for this workload
❌ Resharding is expensive — use consistent hashing to minimize movement
When to reshard:
- Storage per shard exceeds ~1TB (Postgres starts paginating painfully)
- Writes per shard exceed ~2K/s sustained
- Add shards in powers of 2 — halves the data-movement cost
When resharding, use to minimize data movement. With simple modulo hashing, adding one shard remaps ~50% of keys. With consistent hashing, only ~1/N of keys move — critical when you have 90TB of data.
🎯 Why hash(short_code), not hash(user_id)
The redirect path has no user_id — the request is just /xYz12Aq. Sharding by user would require looking up user first (another hop) or storing routing info in every edge node. Sharding by code makes routing a pure function of the URL.
The viral-link scenario
A single URL hits 1M QPS when a celebrity tweets it. The system shouldn't fall over and shouldn't penalize unrelated traffic.
Layer 1 — 301 + Cache-Control
→ Browsers skip us entirely after first hit. Largest lever.
Layer 2 — CDN edge cache
→ PoPs absorb ~99% of what's left. ~10K QPS reaches origin.
Layer 3 — Request coalescing at Redis
→ If 1000 concurrent misses for the same code hit the same app server,
only one DB read fires. Singleflight pattern.
Layer 4 — Local in-process cache (short TTL)
→ Hot codes get pinned to a 10-second local LRU on the redirect service.
Eliminates Redis round trip on the hottest 1000 codes.
Layer 5 — Throttle analytics for viral codes
→ If clicks/sec for a code > 10K, sample analytics events (1 in 10).
Click count is estimated via counter math. Dashboard accuracy within 1%.
Failure-mode playbook
🔥 Redis cluster down
Redirect Service bypasses cache, reads replicas directly. Latency jumps from 3ms → 8ms. Still under SLA. A on the Redis client prevents connection-pool exhaustion.
🔥 Primary DB down
Redirects still work — read replicas keep serving. Creates fail with 503. Automated failover promotes a replica (~30s). Writes resume on the new primary. Short-code generator skips any IDs lost in-flight.
🔥 Kafka down
Redirect Service producer buffers events in-process (up to memory cap), then drops with a counter metric. Redirects never block. When Kafka returns, buffered events drain. A few minutes of analytics may be lost; the redirect SLA is preserved.
🔥 ID Service down
Each app server has a local pool of ~500 IDs. ~2 minutes of create-request runway. Alert fires immediately. Fallback: a standby ZooKeeper counter takes over with a high-watermark ID bump to prevent collisions.
🔥 Entire region down
DNS steering (Route53 / Cloudflare) removes the region from the pool. Other regions take over. Stateful components (Redis, DB) have async replication to standby regions; promotion is manual (acceptable for non-financial data).
💡 SLO targeting
Redirect: 99.99% (~52 min/year downtime) — user-visible. Create: 99.9% (~8.7h/year) — lower priority, retryable. Analytics: 99% (~3.6d/year) — eventual is fine, alerts on sustained loss. Different tiers per path → different on-call priorities → different infrastructure spend.
Security & Abuse
URL shorteners are constantly abused — for phishing, malware distribution, spam campaigns, and bypassing domain blocklists. An interview-ready design treats abuse as a first-class concern, not an afterthought.
Threat model
| Threat | Attack shape | Defense |
|---|---|---|
| Phishing | Attacker shortens a lookalike URL, blasts via email/SMS | URL scanning on create + at redirect time against Safe Browsing |
| Malware distribution | Short URL points at a drive-by download | Same — plus block known-bad TLDs and newly-registered domains |
| Enumeration / scraping | Attacker iterates shortCodes to harvest mappings | Rate limiting + bloom filter + bit-shuffled IDs |
| Open redirect abuse | Short URL used to bypass a third-party's redirect policy | Interstitial warning page for suspicious targets |
| Custom alias squatting | Attacker grabs brand names (coca-cola, ledger) | Trademark-aware reserved-word list; takedown workflow |
| Mass creation (spam campaigns) | Create 100K codes pointing at spam domains | Rate limit by IP + user + target domain; CAPTCHA on anon creates |
| Internal SSRF via long_url | User submits https://169.254.169.254/metadata/iam | Block private IP ranges + resolve DNS and re-check at create |
Rate limiting (multi-key)
Create endpoint:
create:ip:{ip} → 60 / hour (anonymous)
create:user:{user_id} → 1000 / day (authenticated)
create:domain:{tld} → 500 / hour (per target domain — spam cap)
Redirect endpoint:
redirect:ip:{ip} → 1000 / min (abuse: scraping, bots)
redirect:code:{short_code} → no limit (viral traffic is legit)
Implementation: Redis sorted-set sliding window or token bucket.
Limits are soft — return 429 + Retry-After.
🔑 Why per-target-domain rate limits
Spam campaigns look like: create 10K different short codes, all pointing at evil-spam-site.com. Per-user limits don't catch distributed spam (one account per code). Per-target limits catch it at the common dimension. Combine with a blocklist feed.
URL scanning — create-time vs redirect-time
| Stage | Check | Trade-off |
|---|---|---|
| Create-time (sync) | Safe Browsing lookup API (~100ms), basic pattern check | Adds latency but only to the create path, which is not critical |
| Create-time (async) | Queue for deeper scan (sandbox render, ML phishing classifier) | Code is returned immediately; quarantines set if scan flags it |
| Redirect-time | Quick lookup against a local in-memory bad-URL cache | Must be <1ms — cache synced from threat feeds every few minutes |
async function handleRedirect(short_code: string) {
const { long_url, is_quarantined } = await resolve(short_code);
if (is_quarantined) {
// Don't auto-redirect — show an interstitial warning page
return renderWarningPage(long_url);
}
if (badUrlCache.has(canonicalHost(long_url))) {
return renderBlockedPage();
}
return redirect301(long_url);
}
SSRF protection on create
function validateLongUrl(url: string) {
const parsed = new URL(url);
// Scheme: only http / https
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("BAD_SCHEME");
// No IP literals — force hostname
if (isIPAddress(parsed.hostname)) throw new Error("NO_IP_LITERALS");
// Resolve DNS and check — attacker can point a domain at 127.0.0.1
const addresses = await dnsResolve(parsed.hostname);
for (const ip of addresses) {
if (isPrivate(ip) || isLoopback(ip) || isLinkLocal(ip)) {
throw new Error("PRIVATE_IP_RESOLVED");
}
}
// Length cap (2048 is the HTTP practical limit)
if (url.length > 2048) throw new Error("TOO_LONG");
}
🔥 DNS rebinding is subtle
DNS at create-time may return a public IP, but a later redirect-time re-resolution could return 127.0.0.1. The shortener doesn't make the upstream request itself — only the user's browser does — so the rebinding risk is bounded to the user's own network. Still worth noting: log when a domain's resolved IPs change dramatically between create and redirect.
Reserved words and namespace hygiene
Never allow as custom aliases:
api, admin, login, signup, logout, auth, oauth
static, assets, favicon.ico, robots.txt, sitemap.xml
dashboard, settings, billing, pricing, help, support
Any existing route your API Gateway serves
Reserved for product:
Top 10K brand names from trademark feed (coca-cola, tesla, etc.)
ccTLDs (us, uk, jp) — reserved for future localization
Enforcement: single regex + allow-list file, checked before the conflict check.
Return 403 with a specific error code so UX can explain.
Observability
Good observability is what lets you find the viral link, spot the creeping cache-miss regression, and prove the SLO is being met. Name the metrics, the dashboards, and the alerts — not just "we'll use Prometheus."
Golden signals (per service)
| Signal | Redirect Service | Shortener Service | ID Service |
|---|---|---|---|
| Traffic | redirects_per_sec by region, by code class | creates_per_sec by endpoint | id_allocations_per_sec |
| Latency | p50 / p95 / p99 by cache-hit/miss | p50 / p95 / p99 of POST /urls | p99 of id-reserve RPC |
| Errors | 4xx by code (404, 410, 403), 5xx rate | ALIAS_TAKEN, INVALID_URL, SSRF_BLOCKED counts | Range-exhaustion warnings |
| Saturation | CPU, connection pool, cache-miss ratio | DB connection pool utilization | Local pool depth (alert if <100) |
Business metrics (not just infra)
• URLs created per minute (growth + abuse detection)
• Unique creating users (baseline + anomaly)
• Cache hit rate (should stay > 90%)
• Bloom filter false-positive rate (tune when it drifts > 1%)
• Analytics pipeline lag (Kafka consumer offset vs head)
• URLs quarantined per hour (abuse pipeline health)
• Top 10 short codes by click rate (detect viral → pre-warm caches)
• Creates by authenticated vs anonymous (spam-campaign signal)
Alerts that matter (and a few that don't)
| Alert | Page? | Threshold |
|---|---|---|
| Redirect p99 > 15ms for 5m | Yes | SLO breach — root cause before customers notice |
| Redirect 5xx rate > 0.1% | Yes | User-visible failure |
| Cache hit rate < 80% for 10m | Yes | Sign of cache flush, TTL misconfig, or attack |
| ID pool depth < 50 for 1m | Yes | Creates will fail in ~60s |
| Kafka consumer lag > 1M events | Page if > 30m | Analytics stale; redirect unaffected |
| Quarantine rate spike > 10× | Ticket | Abuse campaign — not a service outage |
| DB CPU > 80% | Ticket | Capacity planning, not incident |
| Replication lag > 5s | Page | Cache-miss fallback may return stale data |
Tracing — where to instrument
Trace: redirect.handle (total 4.2ms)
├─ gateway.auth 0.2ms
├─ gateway.ratelimit 0.3ms
├─ redirect.bloom_check 0.02ms
├─ redirect.cache_get 0.9ms [HIT]
├─ redirect.emit_click_event (async) 0.01ms (non-blocking)
└─ redirect.respond 0.1ms
Spans on miss path:
├─ redirect.singleflight_wait 1.2ms
├─ redirect.db_read 5.1ms
├─ redirect.cache_set 0.8ms
Sampling: 1% of all requests + 100% of slow (>50ms) + 100% of errors.
🎯 Log what you'd ask for at 3am
On a hot-path service, log the bare minimum per request: timestamp, short_code, cache_hit, latency_ms, status, region. Full request bodies are expensive and rarely needed. For creates, log the full request (minus PII) — lower volume, higher forensic value.
💡 SLI / SLO / error budget
SLI: fraction of redirects returning 2xx/3xx under 10ms. SLO: 99.9%. Error budget: 0.1% of 35K/sec = 35 failures/sec is the spending limit. Burn-rate alerts: page when 1h burn > 14× (budget gone in 3 days). This turns "is the service healthy" into a number, not a vibe.
Trade-offs Consolidated
Every decision in this design was a trade. Bundling them in one place makes the reasoning easy to review — and gives a candidate a compact story to walk the interviewer through at the end.
| Decision | We picked | Why | What we gave up |
|---|---|---|---|
| Short-code strategy | Counter + base62 + Feistel shuffle | Zero collisions, 1 DB write/create, compact codes | Requires coordination; shuffling to break enumeration adds code |
| Code length | 7 base62 chars | 3.5T codes — decades of headroom | Slightly longer than 6; negligible UX difference |
| Redirect status code | 301 default, 302 for deletable/expiring | 301 aggressively cached by browsers/CDN → huge cost savings | 301 can't be invalidated in-browser — wrong for mutable links |
| Primary datastore | Postgres initially, shard/Cassandra at 1B+ | Strong consistency for uniqueness; ACID simplifies custom aliases | Single-box limit; scale ceiling requires migration |
| Cache layer | Redis Cluster + in-process LRU for hot codes | 175GB working-set distributed; <1ms lookups | Operational complexity; must plan failover and persistence |
| Bloom filter | In-process, per-node, rebuilt from DB snapshot | Stops enumeration from reaching DB; ~1GB for 1B codes | False positives fall through; needs periodic rebuild |
| Analytics path | Async via Kafka → Flink → ClickHouse | Redirect SLA preserved; flexible OLAP queries | Eventual consistency; pipeline complexity; exactly-once work |
| ID generation | Batched range allocation from Redis | ~1000× fewer coordination RPCs than per-create | Lose up to 1000 IDs on app crash (acceptable) |
| Sharding key | hash(short_code) mod N | Point-lookup routing is O(1); even distribution | Scatter-gather for user lookups; manageable — it's not hot path |
| Abuse protection | Multi-layer: rate limits + Safe Browsing + interstitials | Catches phishing/spam without one heavy ML dependency | False positives on legitimate new domains; appeals workflow needed |
| Consistency model | CP for create, AP for analytics, cache-best-effort for redirect | Right consistency per path, matched to user-visible SLA | More surface area to reason about — but that IS the senior skill |
Where reasonable engineers disagree
💬 301 vs 302 — honestly, it depends
Bitly uses 301 for performance + caching. T.co (Twitter) uses 302 to guarantee every click is logged. YouTube (youtu.be) uses 301. The right answer is the product's answer — call out the trade-off, pick a default, and note when you'd override.
💬 Postgres vs DynamoDB from day one
Postgres starts simpler and supports richer queries (user dashboards). DynamoDB scales infinitely without an ops burden. For an interview, picking Postgres first and articulating the migration trigger is usually stronger than "I'll use DynamoDB and accept the access-pattern constraints."
💬 Snowflake IDs vs centralized counter
Snowflake (timestamp + machine_id + seq) needs no coordination but produces 64-bit IDs → 11 base62 chars. For a URL shortener that's a UX regression. A batched centralized counter wins on compactness; Snowflake wins on multi-region write independence.
💬 Same URL → same code?
Dedupe is a one-liner but adds a read per create. It also surfaces privacy oddness (two unrelated users share a code). Default is usually "new code every time"; dedupe as an opt-in feature for power users.
🎯 The trade-off that defines seniority
The biggest single divide between junior and senior answers is whether the candidate can articulate what they're sacrificing. "We use 301 for performance, which means we can't invalidate a deleted link in browsers for up to N minutes — that's why we pick 302 for expiring links" is a sentence that buys you two levels.
Follow-ups & Common Traps
The last 10 minutes of the interview are where candidates separate. The interviewer stops nodding along and starts probing: "what if...?", "how would you...?", "what breaks when...?". These are the questions worth pre-loading.
Curveball follow-ups
Q:A single URL goes viral — 1M QPS to one short code. What happens?
A: Layered defenses kick in: 301 with Cache-Control means browsers skip us after the first hit. CDN edge absorbs another ~99%. Request coalescing at origin dedupes concurrent cache misses. Local in-process LRU pins the hot code. Analytics sampling turns on to protect Kafka. The database is never touched after the first miss. Total origin load: ~1K QPS for a viral event.
Q:The counter service goes down mid-day. What do creates do?
A: Each app server holds a local pool of ~500 pre-reserved IDs — enough runway for ~2 minutes at peak. Meanwhile, a standby ZooKeeper-managed counter takes over, with its high-watermark bumped safely past any reserved range. If both go down, creates return 503 until either recovers. Redirects are unaffected — they don't depend on the ID service.
Q:How do you delete a URL for GDPR right-to-erasure?
A: Soft-delete in the primary DB (is_deleted = true). Evict from Redis. Trigger CDN purge for the short_code path. For 301-cached redirects already in user browsers, the damage is bounded — we change the in-flight server response to 410 Gone, but existing browser caches expire on their own max-age. For GDPR-critical paths, use 302 by default and short cache TTLs.
Q:How do you support 'clicks in the last 10 minutes' in real-time on the dashboard?
A: Flink 1-minute tumbling window writes counts to Redis keyed by counts:{short_code}:{minute}. Dashboard reads 10 keys, sums them. Sub-second response, zero ClickHouse load. For longer ranges (24h, 7d), query ClickHouse directly — those queries are expected to take a second or two.
Q:Can we support custom domains (acme.com/launch) per customer?
A: Yes — add a domains table (domain → customer_id), terminate TLS at an edge proxy that maps Host header → customer_id, and route to a customer-scoped resolver. The short_code lookup becomes (domain, short_code). ACME/Let's Encrypt handles certs at scale. Ops burden is real: cert renewal, DNS onboarding, subdomain verification.
Q:A short URL gets reported as phishing. What's the takedown path?
A: Abuse endpoint flags the code. Automated pipeline: (1) set is_quarantined = true → redirects now show interstitial, (2) push short_code to the threat-feed cache on all Redirect Service instances, (3) CDN purge so edge stops serving 301. End-to-end in under 60s. Appeal workflow for false positives with manual review.
Q:How would you migrate from Postgres to sharded Postgres at 1B URLs?
A: Dual-write phase: app writes to both old and new storage, reads stay on old. Backfill old data via batched migrate job. Read-switch: shadow-read from new, compare. Full cutover once lag is zero. Revert plan if shards show data divergence. Total migration: weeks, not hours. Short URLs are immutable so no update reconciliation needed — a rare gift.
Q:What if users want analytics on their own URLs, not just total counts?
A: Analytics dashboard is a separate service reading from ClickHouse. Authorization: each query filters by user_id; ClickHouse has a user_id column denormalized at event ingestion (pulled from the urls table via a Flink enrichment step). Per-user query quotas prevent a single user from swamping the OLAP cluster.
Q:How small can the team running this be?
A: At 100M/day, a three-person team is viable: backend (shortener + redirect), data (Kafka/Flink/ClickHouse), SRE (Postgres/Redis/CDN). Managed services do the heavy lifting — Confluent Cloud Kafka, ElastiCache Redis, RDS Postgres, Cloudflare CDN. In-house work is business logic and monitoring. Scaling to 1B/day means adding roles for shard ops and abuse response, not doubling infra engineers.
Q:How do you prevent the short-code space from being enumerated?
A: Three layers: (1) bit-shuffle the counter (Feistel or multiply-mod) so consecutive creates produce non-adjacent codes, (2) aggressive rate limits per IP on /:shortCode, (3) bloom filter rejects most bogus lookups before DB. A determined attacker can still enumerate slowly — but spreading attempts across 3.5T codes at rate-limited speed takes geological time.
Common traps (and how to avoid them)
Using UUIDs as short codes
UUIDs are 128-bit — even base62-encoded they're ~22 chars. Defeats the purpose of a short URL.
✅Use a counter + base62. UUIDs have their place (event IDs, idempotency keys) but not as user-facing codes.
Treating hashing as a collision-free magic trick
md5(url)[:7] sounds neat until birthday-paradox math gives you collisions at surprisingly low load.
✅If you pick hashing, own the collision-resolution path explicitly. Otherwise pick the counter approach.
Synchronous analytics writes on the redirect path
Dropping a DB/OLAP write inline with redirect blows the 10ms SLA and couples availabilities.
✅Fire-and-forget to Kafka. Never let analytics slow down or block the redirect.
Forgetting SSRF on the long_url
Users submit https://169.254.169.254/... which your scanner or preview service then fetches — credential leak.
✅Validate scheme + resolve DNS + reject private IP ranges at create time. Reject IP-literal hostnames outright.
Single Postgres to 'we'll just shard later'
Putting off the shard design means you eventually migrate under fire with inconsistent data.
✅Pick the shard key from day one (hash(short_code)) even if N=1. When you grow, N changes — not the logic.
Ignoring the viral-link case
Designing for 35K peak QPS then being surprised when one celebrity tweet drives 1M QPS at a single code.
✅Call out layered caching + request coalescing explicitly. Interviewers ask the viral question — prime the answer.
Mixing up 301 and 302 mindlessly
Picking 301 'because it's permanent' without thinking about analytics or deletability.
✅State the trade-off out loud: 301 for most, 302 for deletable/expiring/analytics-critical. Pick per-URL if needed.
Skipping the capacity-estimation math
Stating '100M/day' without deriving peak QPS, storage, or cache size. Numbers are the whole point.
✅Always derive on the whiteboard: /86400 for average, ×3 for peak, times entry size for storage. Interviewers watch for this.
🔥 The deepest trap — overbuilding
Reaching for Kafka, Flink, ClickHouse, Cassandra, DynamoDB, ZooKeeper, and Kubernetes in the first five minutes telegraphs pattern-matching, not thinking. Senior answers start small, identify where the simple design fails, and add complexity with explicit triggers. "Postgres holds 100M rows comfortably; we shard when writes exceed 5K/s sustained" beats "I'd use a NoSQL cluster for horizontal scalability" every time.
Quick Revision
A 2-minute scan before the interview. If you can state each of these without looking, you're ready.
Quick Revision Cheat Sheet
Scale anchors: 100M writes/day (~3.5K peak), 1B reads/day (~35K peak), 10:1 R/W.
Storage: ~50 GB/day → ~90 TB over 5 years raw. Shard by year 2.
Short code: 7 base62 chars = 3.5T combos. Counter + Feistel shuffle → base62.
ID generation: Redis INCR with batched range allocation (1000 IDs per reserve).
Read path: Browser → CDN → Redis → DB replica. Bloom filter + singleflight.
Write path: Validate → reserve ID → ON CONFLICT DO NOTHING insert → cache populate.
Analytics: Fire-and-forget to Kafka → Flink windows → ClickHouse + Redis counters.
Redirect status: 301 default (cacheable), 302 for deletable/analytics-heavy.
Sharding key: hash(short_code) mod N — point lookup is single-shard.
Cache sizing: Theoretical 175 GB hot set; ~20 GB in practice captures 95%+ hits.
Bloom filter: ~1 GB for 1B codes at 1% FP. Stops enumeration from hitting DB.
Rate limits: Per-IP, per-user, per-target-domain. Sliding window in Redis.
SSRF defense: Block http only, no IP literals, DNS-resolve and reject private ranges.
SLO: Redirect 99.99% / <10ms p99. Create 99.9% / <50ms p99. Analytics 99%.
Viral defense: 301 + CDN + request coalescing + local LRU + sampled analytics.
Failure isolation: Redirect works when cache/DB-primary/Kafka/ID-service fail individually.
🎯 The 45-minute interview arc
- 0–5 min: Clarify requirements, state SLA targets. Ask: dedupe? TTL? public?
- 5–10 min: Capacity estimation. Derive QPS, storage, cache size out loud.
- 10–15 min: API + data model. Four endpoints, one primary table.
- 15–25 min: HLD — write path, read path, analytics path. One diagram.
- 25–35 min: Deep dive on whichever path the interviewer probes — likely code generation or viral scale.
- 35–40 min: Trade-offs table. 301 vs 302, Postgres vs shard, sync vs async analytics.
- 40–45 min: Follow-ups. Viral, failures, GDPR, multi-region.
💡 The single sentence that defines a senior answer
"The redirect path is CP-leaning on the code existing but AP on analytics; create is CP on uniqueness; analytics is eventual — so we pick different storage, delivery semantics, and SLOs per path." If you can say this and back each claim, you're answering at the right level.