00. Design a URL Shortener¶
~20 min read · Level: intermediate · Part 1 of 4 (Overview → HLD → LLD → Q&A)
Problem¶
A URL shortener takes a long, unwieldy web address and hands back a short one that redirects to it. This is the product behind TinyURL, Bitly, and the t.co links inside every tweet. Someone pastes https://example.com/products/2026/summer-collection?utm_source=newsletter&ref=abc123, and the service returns something like https://sho.rt/aZ8kQ2. Whenever a person clicks the short link, the service looks up the original address and sends the browser there.
The problem looks trivial from the outside — it is, after all, "store a string and give it back." What makes it a genuine system-design question is the shape of the traffic. Reads dominate writes by orders of magnitude, a single popular link can absorb a huge share of that read traffic, and the redirect has to feel instant. Getting those three facts straight is most of the battle. The rest of this study builds the system around them.
To keep the reasoning concrete, thread one scenario through the whole design: a retailer's Black Friday campaign link, sho.rt/bf26, printed on every email and ad. During the sale it peaks at 30,000 redirects/second — one link accounting for most of the system's traffic for a few hours — while the retailer's dashboard team wants click counts they can trust. That one link, and that one tension between instant redirects and trustworthy analytics, will test every decision below.
Functional requirements¶
- Shorten: given a long URL, produce a unique short code and return the short link.
- Redirect: given a short code, return the original long URL fast, as an HTTP redirect.
- Custom alias (premium): let a user request a specific code like
sho.rt/mybrand, subject to availability. - Expiration: allow a link to carry an optional expiry, after which it stops resolving.
- Basic analytics: count clicks per link, and ideally capture referrer and rough geography.
De-scoped for this round, and worth saying out loud so the interviewer knows it is a choice rather than an oversight: real-time malware and phishing scanning, link-preview pages, full user account management, and enterprise folder/organization features. These are real, but they sit beside the core and do not change its architecture.
Non-functional requirements¶
The dominant constraint is read latency under a read-heavy, skewed load. Everything downstream follows from that.
- Latency: a redirect should resolve in well under 100 ms at the application layer, ideally 10–30 ms, so the click feels instant once DNS and TLS are paid for.
- Availability: redirects must stay up. A shortener that cannot resolve links is worse than useless — it silently breaks other people's content. Target four nines or better on the read path. Creating a new link can tolerate a little more fragility than resolving an existing one.
- Consistency: the mapping from code to URL is effectively immutable once created, so eventual consistency on the read path is fine — a newly created link becoming resolvable a second late is acceptable. Analytics can lag by minutes.
- Durability: a mapping must never be lost. Losing one silently breaks a link that may live in printed material or old emails for years.
- Scale-of-skew: a small number of links (a viral tweet, a marketing campaign) will draw a wildly disproportionate share of reads. The design must survive hot keys.
Scale estimation¶
Assume a mid-size service: 100 million new links per month and a read-to-write ratio of about 100:1, which is conservative for this domain.
Writes work out to roughly 100M / (30 × 86,400 s) ≈ 38 writes/second on average. Apply a 10× peak factor for campaign bursts and call it ~400 writes/second at peak. This is a modest write load; no exotic write path is required.
Reads, at 100:1, average ~3,800 reads/second, and with the same peaking behavior plus viral spikes we should design the read path for ~40,000 reads/second at peak. This is the number that shapes the architecture — and note where it comes from: our sho.rt/bf26 campaign link alone contributes ~30,000 of that 40,000 during the sale. The peak is not evenly spread across millions of links; it concentrates on a handful. That single fact is the difference between a design that survives Black Friday and one that falls over.
For storage, each mapping row is roughly: a 7-character code, the long URL (average ~200 bytes, allow up to ~2 KB), an owner id, timestamps, and an expiry — call it ~500 bytes per row including indexes. At 100M/month that is 100M × 500 B = 50 GB/month, or ~600 GB/year, and a few terabytes over the service's life. That fits comfortably on a sharded key-value or relational store; it is not a big-data problem.
The keyspace matters more than the byte count. A 7-character code over a 62-symbol alphabet ([A-Za-z0-9]) gives 62^7 ≈ 3.5 trillion combinations — enough for centuries at 100M/month, so 7 characters is a safe default and we do not need to plan for widening it soon.
Bandwidth is trivial: a redirect response is a few hundred bytes, so 40,000 × ~500 B ≈ 20 MB/s outbound at peak, easily served from cache and CDN.
API sketch¶
POST /api/v1/urls
body: { "long_url": "https://…", "custom_alias"?: "mybrand", "expires_at"?: "2027-01-01T00:00:00Z" }
201: { "short_code": "aZ8kQ2", "short_url": "https://sho.rt/aZ8kQ2", "expires_at": … }
409: alias already taken
GET /{short_code}
302: Location: <long_url> # redirect (see HLD for 301 vs 302)
404: unknown or expired code
GET /api/v1/urls/{short_code}/stats
200: { "clicks": 12403, "created_at": …, "top_referrers": [...] }
DELETE /api/v1/urls/{short_code} # owner only
204: removed
Solutioning¶
Start from the traffic shape and the design writes itself. Reads dominate and the working set is small (a code and a URL), so the first move is to put a cache in front of the store and serve the overwhelming majority of redirects from memory. A read-through cache keyed by short code, sitting in front of a durable key-value store, turns the common case into a single in-memory lookup. The store exists mostly to survive cache misses and cold links. The key reframing: sho.rt/bf26 at 30k reads/second is not a database-scaling problem; it is a read-amplification problem — the same tiny value requested 30,000 times a second. The answer is to cache it, not to shard the store, because sharding spreads distinct keys and does nothing for one hot key.
The second decision is how codes are generated, and this is the real tradeoff of the problem. Two families compete. You can hash the long URL (say, take the first few characters of a base62-encoded hash) — stateless and needing no central coordination, but hashes collide, so you inherit a collision-detection-and-retry loop on every write, and identical URLs from different owners map to the same code unless you salt them. Or you can allocate from a monotonic counter and base62-encode the number — collision-free by construction and simple to reason about, but a naive global counter is a write bottleneck and the codes leak your growth rate (code aB3 today, aB9 next week tells a competitor your volume). The pragmatic resolution, developed in the LLD, is counter-based allocation with per-server ranges: a central allocator hands each application server a block of a few thousand IDs at a time, so servers mint codes locally with no coordination, collisions are impossible, and the central allocator is touched only once per block rather than once per write.
The third tension is cache freshness versus deletion and expiry, and it is worth putting numbers on. A 24-hour TTL on hot entries pushes the cache hit ratio to ~99%, which drops store reads from the 40k/s peak to a few hundred QPS — the store barely notices Black Friday. But that same 24-hour TTL means a link the owner just deleted can keep resolving from cache for up to 24 hours. Since mappings are immutable, the resolution is asymmetric: use long TTLs for the happy path (they cost nothing on the common case) and handle the rare delete/expiry explicitly with active cache invalidation plus a short negative-cache for unknown codes, rather than shortening every entry's TTL and paying that hit-ratio tax on every one of 40,000 reads per second.
Two smaller decisions round out the picture. Analytics is pushed off the redirect path entirely — the redirect fires an asynchronous event to a queue and returns immediately, so click-counting can never slow a click or take down redirects. And custom aliases require a synchronous uniqueness check on write, which is fine because writes are rare; this is the one place we accept a little write-path latency in exchange for the guarantee a user is paying for.
The result is a system whose read path is a cache lookup with a database fallback, whose write path is a local code mint plus one durable insert, and whose analytics ride a separate asynchronous rail. The following files take each of these decisions down to components (HLD) and then to schemas, algorithms, and edge cases (LLD).