Skip to content
QSWEQB
hardDesignCase StudyMidSeniorStaff

Design a URL shortener.

Clarify scale first, then size it: 100M new links a day at a 100:1 read ratio gives roughly 1.2k writes/s and 116k redirects/s, needing 7-character base62 keys. Generate keys from leased counter blocks scrambled by a bijection, serve reads from cache and CDN, and redirect with 302 rather than 301.

6 min readUpdated 2026-07-26

What the interviewer is scoring

  • Whether you clarify read/write ratio, link lifetime and custom-alias support before drawing anything
  • Whether your capacity numbers are visibly derived from stated assumptions rather than asserted
  • Whether you can defend one key-generation scheme against the alternatives instead of listing all three
  • Whether you reason about the redirect status code as a product and analytics decision, not a syntax detail
  • Whether you name failure modes specific to this system, such as scan traffic on non-existent codes, rather than generic "add a replica"

Answer

What I need to know before designing

Three answers change the design materially. First, the read-to-write ratio, because a shortener is a read-amplifying system and the ratio decides whether the interesting engineering is in the write path or the cache. Second, whether links expire, because a system with no expiry never reclaims storage and never faces the code-reuse question. Third, whether users can choose their own alias, because custom aliases collide with generated keys and force a uniqueness check that a pure generator would not need.

I will assume 100 million new links per day, a 100:1 read-to-write ratio, links that live indefinitely by default with optional expiry, and custom aliases as a supported but minority feature. The functional surface is small: create a short code for a long URL, resolve a code to its URL, and expose click counts. Non-functionally, redirect latency is the thing users feel, so I will target single-digit milliseconds at the edge and treat redirect availability as far more important than create availability.

Capacity, derived from those assumptions

Writes are 100 million divided by 86,400 seconds, which is about 1,160 per second on average. Assuming a 3x diurnal peak, roughly 3.5k writes per second. Reads at 100:1 are 10 billion per day, about 116k per second average and 350k per second at peak. That gap is the whole shape of the design: the write path is small enough for a single well-partitioned datastore, while the read path needs caching and edge presence.

For storage, a row holds the code, the long URL, an owner id, a created timestamp and an optional expiry. Long URLs vary, but 200 bytes is a reasonable average, so with index overhead assume 500 bytes per link. That is 50 GB per day, roughly 18 TB per year, so a five-year horizon is under 100 TB — large but unremarkable for a partitioned store. Response bandwidth is modest because a redirect is only headers: at about 500 bytes per response, 116k reads per second is under 60 MB/s.

Keyspace length falls straight out of the write rate. Base62 gives 62^7 ≈ 3.52 × 10^12 codes for seven characters, against 3.65 × 10^10 new links per year, so seven characters last on the order of a century. Six characters give 62^6 ≈ 5.68 × 10^10, which is about eighteen months of headroom — not enough. Seven characters it is.

Key generation, and why I pick this one

There are three credible schemes. Hashing the long URL and truncating to seven base62 characters is attractive because it deduplicates identical URLs for free and needs no coordination, but truncating to about 42 bits of entropy against 10^10-scale insertions makes collisions routine rather than theoretical, so every insert needs a conditional write and a retry-with-salt loop. A single global counter encoded in base62 is collision-free by construction but makes one component the bottleneck and the availability floor for all creates, and it emits sequential codes.

I would use the third option: each application node leases a block of counter values — say one million at a time — from a small coordination store, then encodes values from its block. Leasing amortises coordination to one round trip per million creates, so the coordinator being briefly unavailable does not stop creates, especially if each node keeps a second block in reserve. Because the block allocator is a compare-and-swap on a durable counter, a node crashing loses at most one block, which leaves a harmless gap rather than a duplicate.

The cost is that raw counter values are guessable, and guessable codes let anyone enumerate every link in the system. So I do not encode the counter directly; I pass it through a keyed bijection over the integer range before base62 encoding — a small keyed Feistel network. A bijection cannot collide, which is the entire point: it keeps the counter's collision-freedom while destroying the ordering. Be careful about which bijection you name, because this is a place interviewers push. Multiplication by a constant modulo 62^7 is also a bijection, but only if the constant is coprime to 62^7 = 2^7 · 31^7, so "any odd number" is wrong — 31 is odd and collapses the range onto a thirty-first of itself. It is also a linear map, so an attacker who creates two links of their own recovers the multiplier and can enumerate the whole keyspace. It hides ordering from a casual observer; it does not resist enumeration, which was the reason for scrambling in the first place. I am consciously giving up hash-based deduplication, which I would add back later as an optional secondary index on a hash of the URL, since two users shortening the same URL usually want separate codes for separate analytics anyway.

Storage and the read path

The primary store is a key-value or wide-column store partitioned on the short code, because every read is a single-key point lookup and needs no scan or join. Since bijection output is uniformly distributed, hash partitioning spreads load evenly with no hot-shard risk from key layout. Secondary access patterns — a user listing their links, or dedup lookups — go through separate indexes so they never sit on the redirect path.

Reads go cache-first. A distributed cache holding the hottest twenty million links at roughly 250 bytes each is about 5 GB, which is trivially affordable, and because link popularity is heavily skewed, a cache that small absorbs the large majority of traffic. In front of that, the redirect response itself is cacheable at a CDN, which is what actually keeps 350k requests per second away from the origin.

The redirect status code is the decision that deserves argument. A 301 tells browsers the mapping is permanent, so subsequent clicks never reach the service — cheap, but it destroys click analytics and makes it impossible to retarget or disable a link, because you cannot recall a cached 301 from a billion browsers. I would return 302 with a short Cache-Control max-age, accepting the traffic in exchange for keeping the mapping mutable and measurable. Clicks are recorded by publishing an event to a log-based queue and aggregating asynchronously, never by a synchronous counter increment on the redirect path.

HTTP/1.1 302 Found
Location: https://example.com/very/long/path?utm_source=x
Cache-Control: private, max-age=300

Failure modes

The one people miss is scan traffic. Attackers and crawlers probe random seven-character codes, all of which miss the cache and hit the datastore, so misses must be cached negatively and rate-limited per client — otherwise the store absorbs a miss storm it was never sized for. A viral link is the mirror image: a single hot key can saturate one cache node, which argues for request coalescing and for replicating hot entries across nodes rather than relying on consistent hashing alone.

Read-your-writes is a real bug here. If creates commit to a primary and redirects read a replica, a user who shortens a link and immediately clicks it can get a 404 from replication lag. Either serve fresh codes from the primary for a short window or route by code to a replica known to be caught up. Finally, codes must never be reused after expiry, because a recycled code silently sends every previously shared link to a stranger's destination — expired codes get tombstoned, not returned to the pool. Abuse handling needs the same mutability argument as the status code: a phishing link has to be killable at the edge within seconds.

The trap

The trap is answering the redirect with a 301 because it is cheaper. It reads like an optimisation and it is the one irreversible decision in the design: once a permanent redirect is cached in end-user browsers and intermediary caches you have permanently lost the ability to change the destination, count the click, or take down an abusive link. Candidates who reach for 301 usually have not connected the status code to the analytics and moderation requirements, and a good interviewer will probe exactly there.

Everything in a URL shortener follows from one derived number: reads outnumber writes a hundred to one, so the design lives or dies on the cache and the redirect, not on how clever the key generator is.

Likely follow-ups

  • How do you support user-chosen custom aliases without breaking the generated keyspace?
  • A single link goes viral and takes 40% of all traffic. What breaks first, and what do you change?
  • How would you implement per-link click analytics without putting a write on the redirect path?
  • What has to be true for you to safely delete expired links, and why is reusing their codes dangerous?

Related questions

Further reading

url-shortenercapacity-planningkey-generationcachinghttp-redirects