What this module covers: One complete system, designed end to end, using every tool from Phase 1. A URL shortener looks trivial and isn't — it has a brutally hot read path, a key-generation problem with real trade-offs, an analytics requirement that fights the latency requirement, and an abuse surface. This is a worked design, not an exercise: requirements, estimation, API, key generation with the collision arithmetic done, schema and indexes, the redirect hot path and its cache hierarchy, analytics that don't slow the redirect, abuse prevention, a capacity plan, what breaks first — and an explicit list of what I deliberately did not build, with the signal that would change each decision.
1. Requirements
Following Module F-1: functional requirements are short, non-functional requirements are the design.
Functional (in scope)
Submit a long URL, receive a short one.
Visiting the short URL redirects to the long one.
Optional custom alias (short.ly/spring-sale).
Optional expiry.
Per-link click analytics: total clicks, plus breakdown by day, referrer, and country.
Explicitly out of scope (this phase)
Multi-region deployment (Module A-9).
Custom domains per customer.
Link editing after creation.
Real-time analytics dashboards — daily aggregates are enough.
Writing down what's out is half the value, as Module F-1 argued. "Link editing" in particular has a large hidden cost that we'll see when we get to caching.
Non-functional
Requirement
Target
Why this number
Redirect latency
p99 < 50ms at the edge
It's on the critical path of someone else's page load
Redirect availability
99.99%
A dead redirect breaks links already printed, emailed, and posted
Creation latency
p99 < 300ms
Interactive but not latency-critical
Click-count staleness
Up to 5 minutes
Nobody is making decisions on a per-second click count
Durability
A created link is never lost
The URL is in circulation; losing it is unrecoverable
Short code length
≤ 7 characters
Product requirement — it has to be short to be useful
Note the asymmetry that shapes everything: redirects must be fast and always available; creation can be slower; analytics can be stale. Three different requirements on three paths, and conflating them is how you end up making redirects wait for an analytics write.
2. Estimation
Module F-3's four calculations.
text
Four conclusions land immediately, before any design:
This is an overwhelmingly read-heavy system (100:1). Caching is the primary tool, and the redirect path deserves nearly all the design attention.
The write path is tiny. 200 creates/s at peak is comfortable for a single Postgres primary (Module F-3's anchors). No sharding, no queue for creation.
19,000 redirects/s is well past one Postgres. With a 95% cache hit rate that's ~950/s reaching the database — fine. The cache is load-bearing, not an optimisation (Module F-12).
7 characters is plenty of keyspace, using 0.17% in five years. That number matters for the collision analysis next.
3. API
Following Module F-8.
text
Two decisions worth stating:
Creation takes an idempotency key. It's a POST, so it isn't idempotent by definition (Module F-8), and a client retrying after a timeout must not create a second link. Module P-16 covers the full mechanism; here it's a stored key with the resulting code, returned on a repeat.
Delete is soft, and codes are never reused. A reused code would redirect an old, in-circulation link to a new destination — which is both a broken-link problem and an open-redirect security problem. The keyspace is 3.5 trillion; retirement is free.
4. Key Generation: The Actual Design Problem
Four approaches, each with a real failure mode.
Option A: Hash the URL and truncate
base62(sha256(url))[0..7]. Deterministic, so the same URL yields the same code — which sounds elegant and is usually wrong: two users shortening the same URL then share analytics, and one deleting it affects the other. It also can't support per-user expiry. Rejected on product grounds, before we even discuss collisions.
Option B: Random 7-character base62, with collision detection
Generate randomly, insert, and let a UNIQUE constraint catch collisions. Do the arithmetic rather than hand-waving:
text
So collisions across the whole corpus are certain — with 6 billion codes drawn from 3.5 trillion slots, the birthday bound puts the expected number of collision events in the millions — but each individual insert has a 0.17% chance of hitting one, which a unique constraint plus a retry handles for essentially free. That's the honest framing: collisions are a normal, handled event, not a rare accident.
Cost, named: every create needs a database round trip to detect the collision, so the write path can't be fully decoupled from the database. At 200 creates/s that's irrelevant.
Option C: A global counter, base62-encoded
Take a monotonically increasing integer and encode it. No collisions ever, and the shortest possible codes early on.
Two serious problems:
The counter is a coordination point. A single sequence in one database means every create serialises through it, and it becomes the write bottleneck and a single point of failure. Mitigated by range allocation: each application instance claims a block of 10,000 IDs from a coordination store and hands them out locally, refilling when it runs low. Now creates are local, and a crashed instance just wastes at most 10,000 codes out of 3.5 trillion.
Sequential codes are enumerable.aB3xY9z next to aB3xYA0 means anyone can walk the entire corpus, harvesting every shortened link — including private documents people assumed were unlisted. This is a real and repeatedly-exploited class of vulnerability in link shorteners, and it's the same information-leak concern Module P-3 raises about monotonic public IDs.
Option D: Pre-generated key pool
A background job generates random codes, verifies uniqueness, and writes them to a available_codes table. Creation claims one with SELECT ... FOR UPDATE SKIP LOCKED. No collision retries on the hot path, non-enumerable codes, and creation latency is a single indexed read.
Cost, named: another moving part — a generator job to monitor, and a pool that must never run dry. Add an alert on pool depth.
The decision
Option B (random with collision detection) for this design, with a DesignDecision recorded:
typescript
If creation volume were 50,000/s, Option D would win — the round trip per create would matter, and the pool moves that cost off the hot path. The design follows the numbers, which is the whole method.
Custom aliases go through the same UNIQUE constraint, in the same namespace, so a custom alias can never collide with a generated one. Reserve a denylist (api, admin, login, static, plus profanity) before launch, because you cannot reclaim short.ly/admin after someone takes it.
5. Schema
Following Module F-13.
sql
Three details that matter:
code is the primary key, and it's what the hot path looks up. A bigint surrogate key with a unique index on code would add an index hop for no benefit, since nothing else references links by a numeric ID.
The CHECK on the URL scheme is a security control, not validation hygiene. Allowing javascript: or data: URLs turns your shortener into an XSS delivery service, and this is the one place it can be enforced against every writer (Module F-13's argument for constraints over application checks).
link_stats_daily is pre-aggregated. At 10 billion redirects/month, one row per click is 10 billion rows/month — a data warehouse problem (Module P-22), not a Postgres table. Aggregating on the way in is what keeps this in one database.
6. The Redirect Hot Path
19,000 requests/s at p99 under 50ms. Every layer of Module F-12's hierarchy earns its place.
text
Expected hit rates and what reaches the origin:
Layer
Hit rate
Requests passed down
CDN
60%
7,600/s
In-process LRU
40% of remainder
4,560/s
Redis
90% of remainder
456/s
Postgres
—
456/s
456 QPS of primary-key lookups is comfortable for one Postgres with a replica (Module F-3's anchors). The three cache layers turned a 19,000/s problem into a 456/s problem — and per Module F-12, that also means the origin is now sized for 2.4% of traffic, so cache loss is an availability event. §10 addresses that.
The status code decision, which is more consequential than it looks
typescript
301 (permanent) is the tempting choice: browsers cache it indefinitely, so repeat visits never reach us at all — the cheapest possible redirect. It costs two things that outweigh that:
You lose analytics permanently for repeat visitors. Their browser stops asking, so the click is invisible. For a product whose value proposition includes click tracking, that's a feature failure, not a performance win.
You can never change or revoke the destination. A 301 cached in millions of browsers is effectively unrecallable. If a link is later found to point at malware, you cannot take it back — and the "no link editing" scope decision from §1 is precisely this constraint showing up early.
So: 302 with s-maxage=300. The CDN absorbs the volume (a 5-minute shared TTL at 19,000/s is a very high hit rate), browsers keep asking, analytics stays accurate, and revocation takes effect within five minutes. The named cost: no browser-cache layer, so our CDN carries traffic a 301 would have eliminated.
410 Gone for expired links, not 404. It tells crawlers and clients the resource existed and is deliberately gone, which is semantically correct and stops search engines from retrying.
7. Analytics Without Slowing the Redirect
Recording a click must not add latency to the redirect, and must not lose clicks. Those pull in opposite directions.
What not to do:void recordClick(...) after responding. Module F-10 is explicit — on any platform that can freeze or destroy the execution context after the response, this loses a non-deterministic fraction of clicks, silently. Even on a long-running server it loses whatever is in flight when a process restarts.
The design:
typescript
Why each piece is there:
Buffering makes the redirect path do zero I/O for analytics. The cost is up to one second (or 1,000 events) of clicks lost if a process dies uncleanly. That's an explicit accepted cost against the stated requirement — click counts may be up to 5 minutes stale, and approximate. If clicks were billable, this would be the wrong trade and every event would need durable enqueueing before responding.
Batching turns 19,000 writes/s into ~19 batched writes/s. Module F-2's argument, applied: sequential batched appends instead of scattered small writes.
The consumer aggregates with an upsert, so at-least-once delivery from the queue is safe — a replayed batch would double-count with a plain insert.
Country comes from the CDN, which already resolved it. Doing a GeoIP lookup on the redirect path would add latency for information we're being handed for free.
8. Abuse: The Part That's Easy to Forget
A URL shortener is an obfuscation service, so it will be used for phishing and malware distribution. Designing for it after launch means designing it during an incident.
Per-IP and per-account creation limits. Token bucket in Redis (Module P-18): 10/minute anonymous, higher for authenticated accounts. Enforce at the gateway (Module F-15) so abusive traffic never consumes application capacity.
URL scheme allowlist, enforced in the database (§5). No javascript:, no data:, no file:.
Reject self-referential and internal targets. A shortener pointing at its own domain enables redirect loops; one pointing at 169.254.169.254 or an internal IP range is an SSRF vector if anything ever server-side fetches the target (for a preview image, say). Block private and link-local ranges at creation.
Reputation check on creation, asynchronously. Submit new URLs to a safe-browsing API from the queue rather than inline; disable the link if it comes back bad. Inline checking adds a third-party dependency to your creation latency and availability (Module A-7).
An interstitial warning page for links flagged as suspicious rather than a hard block — a hard block on a false positive breaks a legitimate customer's campaign with no recourse.
A revocation path that actually works within minutes. This is the operational reason s-maxage=300 and 302 were chosen in §6. A design that can't revoke a link is a design that will host malware indefinitely.
9. Capacity Plan
Component
Sizing
Basis
CDN
Managed
60% of 19,000/s = 11,400 req/s absorbed
App instances
8 × (2 vCPU, 4 GB)
7,600/s ÷ ~2,000 req/s per instance (redirects are trivial work), ×2 headroom for Module F-4's utilisation target
Redis
1 primary + 1 replica, 8 GB
20M hot codes × ~150B ≈ 3 GB, plus headroom (Module F-12)
Postgres
1 primary + 1 replica, 16 vCPU, 64 GB
456 read QPS + 200 write/s; 1.8 TB over 5 years
Queue
Managed
~19 batched writes/s
Aggregation workers
2
Batch upserts, not latency-sensitive
Total: a small, boring deployment for a system serving 10 billion redirects a month. That's the point of Phase 1 — most of the capability came from the cache hierarchy and one well-chosen index, not from a large fleet.
10. What Breaks First
Design isn't finished until you've named the failure modes.
Redis goes down. The 456 QPS at Postgres becomes 4,560 QPS instantly (the in-process LRU still absorbs its share) — near the limit of one primary. Mitigations: keep the read replica serving redirect lookups, keep the in-process cache large enough to matter, and treat a Redis outage as a load-shedding event rather than a total one. Never fail a redirect because Redis is unavailable — fall through to Postgres, and let the higher latency be the degradation (Module A-7).
Cold cache after a deploy or Redis restart. The worst case: 19,000/s arriving with every layer empty. Mitigations: don't flush the in-process cache on rolling deploys (only some instances restart at a time), pre-warm Redis with the top N codes on startup, and rely on the CDN — which does not get flushed by our deploy — to absorb the majority throughout. Request collapsing at the edge (Module F-15) prevents 300 PoPs from all missing at once.
A single link goes viral. One code taking 50,000 req/s. This is the easy case: the CDN serves nearly all of it from one cached entry, and the in-process LRU catches the rest. A hot key is the case caching handles best — unlike a hot partition on the write path, which Module P-6 covers.
The database primary fails. Redirects continue from cache and the read replica; creation stops until failover completes. Given the read/write asymmetry, that's a good failure mode — the 100:1 side of the traffic keeps working. Worth writing down as the intended behaviour, so nobody "improves" it by making redirects depend on the primary.
A bad deploy changes the cache key format. Instant 0% hit rate across all layers — self-inflicted, and exactly what versioned cache keys prevent (Module F-12).
11. What I Deliberately Did Not Build
Each of these is a real decision with a named trigger, in the spirit of Module F-1.
Not built
Why not
Revisit when
Multi-region
Single region + CDN meets p99 < 50ms, since redirects are cacheable at the edge. Multi-region writes need conflict resolution (Module A-9).
Creation latency matters to users far from the region, or regulation requires data residency
Sharded database
1.8 TB over five years and 200 writes/s is single-primary territory
Approaching ~5 TB or ~5,000 writes/s (Module P-6)
Pre-generated key pool
Collision retry rate is 0.17%, which is free at 200 creates/s
Creation exceeds ~5,000/s, or load factor passes 10%
Per-click event storage
10B rows/month is a warehouse problem; daily aggregates satisfy the requirement
A requirement for per-click drill-down or session-level analysis appears (Module P-22)
Real-time analytics
5-minute staleness was the stated budget
A product requirement for live dashboards — which means streaming aggregation (Module P-15)
Link editing
Would require immediate global invalidation, and interacts badly with cached redirects
If added, s-maxage drops and tag-based purging becomes mandatory (Modules F-15, P-13)
That last row is the honest one: the scope decision in §1 and the caching decision in §6 are the same decision, made once. Adding link editing later isn't a feature, it's a change to the caching architecture.
Summary
Requirements first. The asymmetry — fast, always-available redirects; slower creation; stale-tolerant analytics — determines every subsequent choice.
Estimation decides the architecture: 100:1 read-heavy means caching is the primary tool; 200 creates/s means one Postgres; 19,000 redirects/s means the cache is load-bearing; 0.17% keyspace load makes random codes viable.
Key generation has four options with real trade-offs. Hashing breaks the product model, counters are enumerable (and coordination points), random needs collision detection, and a pre-generated pool moves that cost off the hot path. Do the arithmetic and choose from the numbers.
Codes are never reused, because a reused code is both a broken link and an open-redirect risk.
The schema carries security: a CHECK on the URL scheme is the one place javascript: can be blocked against every writer. Partial indexes keep the sweeper cheap.
Three cache layers turn 19,000/s into 456/s — and thereby make cache loss an availability event that has to be designed for.
302 with a short s-maxage beats 301 because a permanently-cached redirect destroys analytics and makes revocation impossible.
Analytics buffers in memory and flushes in batches to a durable log, with idempotent aggregation — never fire-and-forget after the response.
Abuse prevention is part of the design: creation rate limits, a scheme allowlist, SSRF-safe target validation, asynchronous reputation checks, and a revocation path that works within minutes.
Name what breaks first — Redis loss, cold cache, primary failure — and make redirects degrade rather than fail.
Name what you didn't build, and the signal that changes it. A design without that list isn't finished.
Phase 1 is complete. You have the machine (F-2), the numbers (F-3, F-4), the request path (F-5, F-6), the application shape (F-7, F-8), the runtime choices (F-9, F-10), and the layers in front (F-11, F-12, F-15) — enough to design and defend a single-region system properly.
Phase 2 breaks the single-region assumption. Module P-1 starts with ACID and isolation levels: what a transaction actually guarantees, and the anomalies that appear when you assume it guarantees more.
Knowledge Check
The design uses random 7-character base62 codes with a UNIQUE constraint rather than a base62-encoded counter. What is the primary reason, and what cost is accepted?
Why does the design return 302 with s-maxage=300 rather than 301, given that a 301 would let browsers cache indefinitely and eliminate most traffic?
Click recording appends to an in-memory buffer flushed every second or every 1,000 events. What does the module say about this, and when would it be the wrong design?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Assumption: 100M new links/month, read:write ratio 100:1
WRITES
100M / 2.6M s = ~38 creates/s average
× 5 peak factor = ~200 creates/s peak (campaign-driven, so bursty)
READS
10B redirects/month / 2.6M s = ~3,850 redirects/s average
× 5 peak factor = ~19,000 redirects/s peak
STORAGE (per link row: code 8B, URL ~120B avg, owner 8B, timestamps 16B,
plus row and index overhead → call it 300B)
100M × 300B = 30 GB/month
5 years = 1.8 TB logical
× 3 replicas × 1.5 (index overhead) ≈ 8 TB paid storage
BANDWIDTH
Redirect response is a 301/302 with a Location header: ~500 bytes with headers.
19,000/s × 500B = ~10 MB/s egress. Trivial.
KEYSPACE
base62, 7 characters = 62⁷ = 3.52 × 10¹² codes
5 years of usage = 6 × 10⁹ codes
Load factor = 0.17% of the keyspace consumed
POST /links
{ "url": "https://example.com/very/long/path", "alias": "spring-sale",
"expires_at": "2027-01-01T00:00:00Z" }
→ 201 { "code": "aB3xY9z", "short_url": "https://short.ly/aB3xY9z" }
Header: Idempotency-Key: <client-generated UUID>
GET /:code → 302 with Location (the hot path)
GET /api/links/:code → link metadata (owner only)
GET /api/links/:code/stats?from=&to= → aggregated analytics
DELETE /api/links/:code → soft delete (the code is never reused)
Keyspace N = 3.52 × 10¹²
Codes used n = 6 × 10⁹ (after five years)
Load factor = n/N = 0.0017
Probability that any single insert collides with an existing code ≈ 0.17%
Expected retries per insert ≈ 0.0017
const keyGenerationDecision: DesignDecision ={ decision:'Random 7-char base62 codes, uniqueness enforced by a DB constraint', axis:'creation volume is low (200/s peak) and codes must not be enumerable', chosenBecause:['non-enumerable — no corpus harvesting','no coordination point, no counter to shard or fail over','one moving part fewer than a pre-generated pool',], acceptedCost:['each create needs a DB round trip to detect collisions (~0.17% retry rate)','retry rate rises as the keyspace fills — revisit past ~10% load factor','codes are 7 chars from day one, not 1–2 chars as a counter would give',], revisitWhen:'creation exceeds ~5,000/s, or load factor passes 10%',};
CREATETABLE links ( code varchar(16)PRIMARYKEY,-- the lookup key; also the cache key long_url textNOTNULLCHECK(length(long_url)<=2048), owner_id bigintREFERENCES users(id),-- nullable: anonymous links allowed created_at timestamptz NOTNULLDEFAULTnow(), expires_at timestamptz,-- nullable: no expiry deleted_at timestamptz,-- soft delete; code never reusedCHECK(long_url ~'^https?://')-- no javascript:, no data:);-- Owner's dashboard: their links, newest first. Equality column first, then the-- sort column — F-13's leftmost-prefix rule, and F-8's cursor pagination shape.CREATEINDEX links_owner_created ON links (owner_id, created_at DESC)WHERE deleted_at ISNULL;-- Expiry sweeper only ever wants unexpired, undeleted rows: a partial index-- keeps it small regardless of how large the table gets.CREATEINDEX links_expiring ON links (expires_at)WHERE expires_at ISNOTNULLAND deleted_at ISNULL;-- Daily click aggregates. NOT one row per click — see §7.CREATETABLE link_stats_daily ( code varchar(16)NOTNULLREFERENCES links(code),daydateNOTNULL, country char(2)NOTNULL, referrer textNOTNULL,-- normalised to a host clicks bigintNOTNULLDEFAULT0,PRIMARYKEY(code,day, country, referrer));
Client
│
├─ Browser cache? ── hit → 0 ms, never leaves the device
│
▼
CDN edge (Module F-15)
│ cache key = path only; nothing here varies by user
├─ hit → ~10 ms, never touches our infrastructure
│
▼
Load balancer (Module F-11) → app instance
│
├─ in-process LRU (10k hottest codes, 60s TTL) ── hit → ~0 ms
│
├─ Redis (Module P-12) ─────────────────────── hit → ~1 ms
│
▼
PostgreSQL primary-key lookup ──────────────────── ~2 ms
app.get('/:code',async(req, res)=>{const link =awaitresolve(req.params.code);// walks the cache hierarchyif(!link)return res.status(404).render('not-found');if(link.expired)return res.status(410).render('expired');// Gone, not 404// 302 (temporary) + a short shared TTL. Deliberate — see below. res.set('Cache-Control','public, max-age=0, s-maxage=300'); res.redirect(302, link.longUrl);// Analytics is NOT awaited here — and NOT fire-and-forget either. See §7.});
// 1. On the redirect path: append to an in-memory buffer. No I/O, no await.const buffer: ClickEvent[]=[];functionrecordClick(code:string, req: Request){ buffer.push({ code, at: Date.now(), country: req.headers['cf-ipcountry']??'XX',// the CDN already resolved it referrer:normaliseHost(req.headers.referer),});if(buffer.length >=1000)voidflush();// size-based flush}// 2. Flush on a timer or at size: one batched write to the durable log.setInterval(()=>voidflush(),1000);asyncfunctionflush(){const batch = buffer.splice(0, buffer.length);if(batch.length)await queue.sendBatch(batch);// Kinesis/SQS/Kafka}// 3. A separate consumer aggregates into link_stats_daily with an upsert,// so replayed batches are idempotent (Module P-16).// INSERT ... ON CONFLICT (code, day, country, referrer)// DO UPDATE SET clicks = link_stats_daily.clicks + EXCLUDED.clicks;