Overview
A URL shortener maps a long URL to a short code (dev.zy/aZ3k9Qx) and redirects anyone who opens it. The idea is simple. Doing it at internet scale is not: the service sits on the critical path of every click, so it has to stay fast, always available and hard to abuse, even when a single link goes viral.
This teardown walks through a design that handles billions of links and tens of thousands of redirects per second, and shows what happens when things go wrong.
THE ONE-LINE DESIGN
Reads dominate writes 100:1, so we make the redirect path a cache lookup (CDN, then Redis, then a sharded key-value store) and make code generation coordination-free with leased ID ranges, so neither path becomes a bottleneck.
Requirements
Functional
- Create a short link from a long URL
- Redirect a short link to its long URL
- Optional custom alias and expiry date
- Disable or delete a link
- Click analytics (count, country, device, referrer)
Non-functional
- Redirect availability 99.99%
- Redirect latency p99 under 50 ms at origin
- Durability: a created link is never lost
- Scale: 100M new links per month, 100:1 read ratio
- Codes are short and not guessable in sequence
Out of scope
Billing, team accounts, link previews and a full analytics dashboard UI. The design leaves room for them but does not depend on them.
Capacity estimation
Back-of-the-envelope numbers decide the architecture. The read/write split is what matters most here.
| Quantity | Estimate | How |
|---|---|---|
| New links | ~40/s avg, ~400/s peak | 100M / 30 days / 86,400 s, peak at 10x |
| Redirects | ~4,000/s avg, ~40,000/s peak | 100:1 read/write ratio, peak at 10x |
| Stored records (5 yrs) | ~6 billion | 100M x 12 months x 5 years |
| Storage | ~3 TB (about 9 TB replicated) | 6B records x ~500 bytes, RF=3 |
| Hot cache | ~35 GB | 20% of one day's ~340M reads x 500 bytes |
| Code length | 7 characters | 62^6 is 57B (too tight); 62^7 is 3.5T (ample headroom) |
| Egress bandwidth | ~20 MB/s peak | 40k redirects/s x ~500 byte response |
Nothing here is exotic: the working set fits in a handful of Redis nodes and the data fits in a small sharded store. The hard part is the latency and availability of the read path, not the volume.
API design
A small REST surface. Creation calls accept an Idempotency-Key header so a client retry never creates a second link.
| Endpoint | Purpose | Notes |
|---|---|---|
POST /v1/urls | Create a short link | Body: long_url, optional custom_alias, expires_at. Returns 201 with code and short_url; 409 if the alias is taken. |
GET /{code} | Redirect | 302 to the long URL; 404 if unknown; 410 if expired or disabled. |
GET /v1/urls/{code} | Link metadata | Owner only. |
PATCH /v1/urls/{code} | Update expiry or disable | Triggers cache and CDN invalidation. |
DELETE /v1/urls/{code} | Delete a link | Soft delete first, then purge after a grace period. |
GET /v1/urls/{code}/stats | Click analytics | Aggregated from the analytics store, never from the hot path. |
High-level architecture
Components
- CDN / Edge: terminates TLS, absorbs viral spikes by caching redirects briefly, and runs the WAF and DDoS protection. Edge logs also feed analytics, so cache hits are still counted.
- API Gateway / Load balancer: authentication for the management API, per-key and per-IP rate limiting, and routing of
GETandPOSTtraffic to separate pools. - Redirect Service: stateless and autoscaled on requests per second. It does one thing: resolve a code to a URL as fast as possible.
- Shorten / Manage Service: validates URLs, generates codes, writes records, and handles alias, expiry and delete. It scales independently of redirects.
- Redis Cluster: caches
code to URLwith an LRU policy and a TTL. It also stores negative results so unknown codes do not hit the database. - Sharded KV store: the source of truth (DynamoDB or Cassandra), partitioned by a hash of the code, replicated three ways.
- Range Allocator: hands out blocks of one million IDs to Shorten instances (details below).
- Kafka, stream job, ClickHouse: click events flow asynchronously into an analytics store so reporting never slows a redirect.
The redirect path
- The browser requests
/aZ3k9Qx. The CDN answers from its edge cache when it can (short TTL, 30 to 60 seconds). - On a miss the request reaches a Redirect Service instance, which checks Redis first.
- On a Redis miss it reads the key-value store, then backfills Redis with a TTL capped at the link's expiry.
- It publishes a click event to Kafka without waiting for an acknowledgement, then returns the redirect.
DECISION: 301 vs 302
A 301 (permanent) is cached by browsers, which cuts load but means we can never disable a link, change its target or count its clicks. The design uses 302 so every click reaches us (or our CDN) and control stays with us. The cost is more traffic, which the cache layers absorb.
Generating short codes
The code generator has to produce unique, short and hard-to-guess codes without becoming a bottleneck. Options considered:
| Approach | Strength | Weakness | Verdict |
|---|---|---|---|
| Hash the URL, take 7 chars | Stateless; same URL gives the same code | Collisions need a check and retry; leaks nothing but wastes writes as the table fills | Rejected |
| Random 7 chars | Simple, unguessable | Birthday collisions grow with fill; every write needs a lookup | Rejected |
| Global counter to Base62 | Zero collisions | Single hot counter; codes are sequential and enumerable | Rejected |
| Pre-generated key pool (KGS) | No collisions at write time | Extra service to keep highly available | Viable |
| Leased ranges + keyed permutation + Base62 | No collisions, no per-request coordination, unguessable | Range leases are wasted if an instance crashes | Chosen |
How the chosen approach works
- Each Shorten instance leases a block of 1,000,000 IDs from the Range Allocator (an atomic compare-and-swap on one row in etcd or a database) and renews at 20% remaining.
- For each request it takes the next local ID, so no network call is needed to pick a code.
- A keyed Feistel permutation scrambles the ID into another number in the same range, so consecutive links look unrelated.
- The result is Base62-encoded into 7 characters and written with a conditional put (
if_not_exists).
A crashed instance wastes up to one million unused IDs. With 3.5 trillion possible codes and only ~6 billion used in five years, that waste is irrelevant. Region IDs are placed in the high bits of the ID, so two regions can never hand out the same code.
Custom aliases skip the generator: they are written with the same conditional put, and a conflict returns 409. Aliases are normalised, checked against a reserved-word list (api, admin, login) and length-limited.
Data model and storage
The dominant access pattern is a single-key lookup by code with no joins, which fits a key-value or wide-column store. Account and billing data lives separately in a relational database.
| Table | Key | Fields |
|---|---|---|
urls | Partition key: code | long_url, owner_id, created_at, expires_at, status (active, disabled, blocked), is_custom |
owner_urls (index) | owner_id + created_at | code: powers "list my links" without scanning |
clicks (ClickHouse) | Partition by day, order by code, ts | event_id, country, device, referrer_domain, is_bot |
- Sharding: partition by hash of the code, so writes and reads spread evenly. A managed store handles rebalancing.
- Replication: three replicas per partition, quorum writes, with cross-region replication for disaster recovery.
- Expiry: the store's native TTL removes expired rows. The Redirect Service still checks
expires_at, so a link stops working on time even before the row is physically deleted.
Caching strategy
Because reads are 99% of traffic, caching is the design. There are three layers, each with a clear job:
| Layer | What it caches | TTL / policy | Protects against |
|---|---|---|---|
| CDN / edge | Redirect responses | 30 to 60 s | Viral spikes and repeat clicks; keeps traffic off the origin |
| In-process LRU | Top ~10k codes per instance | 10 to 30 s | Redis hot keys and network hops |
| Redis cluster | Code to URL (plus negative results) | Hours; capped at link expiry; jittered | Database load; stampedes |
- Cache-aside with single-flight: when many requests miss on the same code at once, only one goes to the database and the rest wait for its result.
- TTL jitter stops many keys from expiring at the same moment.
- Negative caching (30 s) stops scanners and typos from hammering the database with unknown codes.
- Invalidation: disabling or deleting a link publishes an invalidation event to every Redis and calls the CDN purge API. The short edge TTL is the backstop.
Scaling and multi-region
- Stateless tiers scale horizontally behind the load balancer, with autoscaling on requests per second and p99 latency.
- Latency-based DNS / anycast sends each user to the nearest healthy region, and health checks remove a failed region within seconds.
- Read-your-write across regions: a new link may not have replicated yet when someone in another region clicks it. On a local miss, the service does a quorum read against a peer region before returning 404, then negative-caches the answer for a short time.
- Capacity headroom: each region is sized to absorb the traffic of a failed neighbour (N+1), so a regional outage is a capacity event, not an outage.
Analytics pipeline
Click analytics must never slow or break a redirect, so it is decoupled completely.
- The Redirect Service emits a small event (
code, timestamp, hashed and truncated IP, user agent, referrer) to Kafka using a non-blocking producer with a bounded local buffer. If Kafka is unavailable, events are dropped rather than delaying the redirect. - CDN access logs flow into the same topic, so edge-cache hits are counted too.
- A stream job deduplicates by
event_id, filters bots by user-agent and IP heuristics, and aggregates into ClickHouse (per code, per minute). - Stats endpoints read the aggregates, so dashboards cost nothing on the hot path.
The counts are at-least-once, then deduplicated: accurate enough for reporting, and explicitly not a billing-grade ledger.
Handling failures and edge cases
A design is only as good as its behaviour when things break. These are the situations it is built for:
| Situation | What happens | How the design handles it |
|---|---|---|
| A single link goes viral (hot key) | 1M requests/s for one code | CDN serves most traffic; in-process LRU and single-flight protect Redis and the database; hot keys can be replicated across cache shards. |
| Cache stampede after eviction | Many requests miss at once | Single-flight per key, jittered TTLs, negative caching. |
| Redis cluster unavailable | Cache layer lost | Fall back to the key-value store with a circuit breaker and load shedding; the store is provisioned to survive a fraction of full read traffic while Redis recovers. |
| Range Allocator down | Instances cannot lease new ranges | Each instance holds about 1M IDs, hours of buffer at peak; leases renew early; alerts fire long before exhaustion. |
| Instance crashes mid-range | Unused IDs lost | Accepted waste; the ID space is 500x larger than needed. |
| Custom alias collision | Two users want the same alias | Conditional put makes exactly one win; the loser gets 409. |
| Client retries a create request | Duplicate links | Idempotency-Key returns the original response. |
| Link expires or is deleted | Stale cached redirect | 410 Gone; cache TTL capped at expiry; explicit invalidation plus short CDN TTL. |
| Region outage | One region lost | DNS fails over; other regions hold replicas and spare capacity; region bits prevent code collisions after failover. |
| Replication lag | New link 404s in another region | Quorum read against a peer region on a local miss before returning 404. |
| Kafka or analytics outage | No click events | Redirects are unaffected; events buffer or drop; counts are backfilled from CDN logs. |
| Invalid or looping target URL | Bad data or redirect loops | Validate scheme (http/https), length (2,048 max) and normalise; refuse targets on our own domain. |
| Traffic surge or bot flood | Origin overload | WAF and rate limits per IP and key; autoscaling; load shedding of low-priority requests. |
Security and abuse prevention
Shorteners are a favourite tool for phishing and malware, so abuse controls are part of the core design, not an afterthought.
- Malicious targets: check every submitted URL against a reputation service (for example Google Safe Browsing) at creation, and re-scan periodically. Bad links are set to
blockedand show a warning page instead of redirecting. - Enumeration resistance: keyed permutation makes codes non-sequential; 404 responses are uniform and rate-limited.
- Rate limits on creation per API key and per IP; reserved words for aliases; HTTPS and HSTS everywhere.
- Privacy: store only a truncated, hashed IP; support deleting all data for an owner on request.
- Scanner isolation: any service that fetches a target URL runs in a sandbox with restricted egress, to prevent server-side request forgery.
Observability and SLOs
- SLOs: redirect availability 99.99% (about 52 minutes of downtime a year), redirect p99 under 50 ms at origin, create availability 99.9%.
- Golden signals per service: request rate, error rate, latency percentiles, saturation.
- Cache health: hit ratio per layer, eviction rate, key-value store read latency and throttling.
- Allocator health: remaining IDs per instance and lease latency, alerting well before exhaustion.
- Pipeline health: Kafka consumer lag and dropped-event counts.
- Synthetic probes create and resolve a link from several regions every minute, and page on failure.
- Safe rollouts: canary deploys, feature flags, and rollback on SLO burn.
Trade-offs and alternatives
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Redirect status | 302 | 301 | Keeps control and analytics; cache layers absorb the extra load. |
| Primary store | Wide-column / KV (DynamoDB, Cassandra) | PostgreSQL | Pure key lookups at high scale; relational features are not needed. Postgres still holds accounts. |
| Code generation | Leased ranges + permutation | Hash or random | No collisions and no per-request coordination. |
| Consistency | Eventual across regions | Strong global | Availability and latency matter more; fixed by quorum read on miss. |
| Analytics | Async, at-least-once | Synchronous counters | Redirect latency must never depend on analytics. |
| Cache invalidation | Short TTL + explicit purge | Long TTL only | Bounded staleness when a link is disabled. |
Possible next steps
- Bloom filters per region to reject unknown codes without a database read.
- Per-link QR codes and deep links for mobile apps.
- Team workspaces, custom domains and branded short links.
- A read-through edge worker so redirects resolve entirely at the CDN.