Designing TinyURL
A URL shortener at full depth: sizing, the API and data model, code generation, the store, the cached read path, click counting, expiry, abuse and multi-region.
On this page
A shortener does one trivial thing: it turns a long URL into a short code and sends anyone who visits the code back to the URL. That is a hash table with a web server in front, and you could write it in an afternoon. What makes it worth a whole article is the ratio: a hundred reads for every write, billions of rows that are never read again, and a redirect that has to answer in single-digit milliseconds from anywhere on earth. Every decision here follows from those three facts.
The map
Read this first when short on time. Every branch is a section below.
What it must do, and at what size
The method from the previous article starts with requirements, so here they are, stated as verbs and exclusions before anything is drawn.
It must accept a long URL and return a short one, redirect a short code to its target, let a caller choose a custom alias, let a link expire or be deleted, and report how many times a link was followed. It must not edit a link's target after creation, serve as a general redirect manager with rules and A/B splits, or guarantee that a short code is secret. Those exclusions matter: an editable target forces cache invalidation on every write and version numbers on every row, and it is the single largest simplification available here.
The promises are lopsided. A redirect is on the critical path of somebody else's page load, so it wants a p99 under about 50 ms including the network, from any continent, at four or five nines of availability. Creating a link is a background action a human waits on, so 200 ms is generous. And consistency is asymmetric too: a link that is a second late becoming visible is fine, but a code that resolves to the wrong target is never fine.
Writes 10M/day -> 10M / 86,400 ~= 115/s -> peak ~350/s
Reads 1B/day -> 1B / 86,400 ~= 11.6k/s -> peak ~35k/s
Ratio ~100 reads per write -> cache-first design
Row code 7 B + URL ~400 B + metadata ~90 B ~= 500 B
Storage 500 B x 10M/day = 5 GB/day -> 1.8 TB/year -> ~5.5 TB replicated x3
5 years ~18 billion links, ~9 TB before replication
Hot set top ~1% of links serve ~90% of redirects
180M links x ~200 B cached ~= 36 GB -> one modest Redis clusterThree conclusions fall out before any component is chosen. The write rate is trivial — 350 writes per second is one database. The read rate is not trivial but is cache-shaped, because the same small set of links is followed over and over. And the dataset is large but cold: eighteen billion rows of which a few hundred million are ever touched again, which means the store must be cheap per row and fast on a single-key lookup, and nothing else.
flowchart TD B["browser follows a short link"] --> E["edge and regional cache"] E --> RS["redirect service, stateless"] RS --> KV[["mapping store, sharded by code"]] RS --> CL[["click log"]] CL --> SA["stream aggregation"] SA --> AN[["analytics store"]] AP["API client creates a link"] --> CS["create service"] CS --> RG["range service, once per million codes"] CS --> KV AN --> SP["stats endpoint"]
- Bitly has reported handling billions of clicks a month against a far smaller number of new links, which is the same hundred-to-one shape.
- Twitter's t.co wraps every posted link, so its write rate tracks tweet volume while its read rate tracks timeline views — a much wider ratio again.
- Enterprise shorteners such as Rebrandly invert the interesting part: low volume, but custom domains and editable targets, which is a different and harder design.
The API and the data model
Write the API first, because it fixes the contract, and then list the queries, because they decide the store.
POST /links { url, alias?, expires_at? } -> 201 { code, short_url }
header: Idempotency-Key: 7f3a
GET /{code} -> 302 Location: <long url>
GET /links/{code} -> 200 { url, created_at, expires_at }
GET /links/{code}/stats?from=&to= -> 200 { total, by_day[] }
DELETE /links/{code} -> 204
GET /links?cursor= -> 200 { items[], next_cursor }Two details on the create call. The idempotency key stops a retried request from minting a second code for the same intent; without it, a client that times out and retries gets two links and reports the wrong one. And creation returns 201 with the code in the body rather than redirecting, because the caller is a program, not a browser.
Now the access patterns, written as sentences, in order of how often they happen:
- Given a code, get the target URL. Eleven thousand times a second. This is the only query that matters for the architecture.
- Given a user, list their links, newest first. Rare, paginated, and tolerant of a hundred milliseconds.
- Given a code and a date range, get click counts. Rare, aggregate-shaped, and a completely different workload from the first two.
The first pattern is a pure key lookup with no joins, no ranges and no ordering, which is the definition of a key-value workload. The second is a range scan by owner, which is a secondary index. The third is an aggregation over events, which belongs in an analytical store entirely — as the analytics article argues, mixing it into the transactional store is how the redirect path gets slow.
links (partition key: code)
code string 7 chars, base62
long_url string up to 2,048 bytes
owner_id string null for anonymous links
created_at timestamp
expires_at timestamp | null -- store TTL uses this
alias bool true if user-chosen
links_by_owner (partition key: owner_id, sort: created_at desc)
owner_id, created_at, code -- a second table, not an index on the hot oneKeep the owner listing in its own table rather than as a global secondary index on links. An index makes every write touch two structures and couples the hot read path's latency to a query nobody runs; a second table written from the same event keeps them independent.
The row is immutable after creation, which is the property the whole design leans on. An immutable mapping can be cached forever, replicated to every region without conflict resolution, and served from a stale replica without being wrong. That one decision, taken in the requirements, is worth more than any optimisation later.
Generating the short code
The code is the product, so it is worth being precise about it. It should be short, URL-safe, unambiguous to read aloud, and impossible to collide.
Base62 — 0-9a-zA-Z — is the standard alphabet: it is URL-safe without escaping and denser than hex. The length follows from the keyspace:
| Length | Keyspace | At 10M links a day |
|---|---|---|
| 5 characters | 625 ≈ 916 million | Exhausted in 3 months |
| 6 characters | 626 ≈ 56.8 billion | Exhausted in about 15 years |
| 7 characters | 627 ≈ 3.52 trillion | About 960 years |
| 8 characters | 628 ≈ 218 trillion | Longer than anyone cares about |
Seven is the answer, and the reason six is wrong is subtler than exhaustion: long before a keyspace is full, random generation into it starts colliding, because by the birthday bound collisions become common at around the square root of the space. Six characters means collisions at a few hundred thousand links, which is nothing.
Now, where does the number behind the code come from? Four approaches, and the trade is always coordination against collisions.
flowchart TD
S["need a new code"] --> Q{Coordination allowed?}
Q -- no --> H["hash the URL, take 7 chars"]
H --> C{Collision?}
C -- yes --> H2["rehash with a salt"]
C -- no --> D["write the row"]
Q -- yes --> R["claim a range of 1,000,000 ids"]
R --> L["encode the next id in base62"]
L --> D
| Approach | How it works | Why it is rejected or chosen |
|---|---|---|
| Hash the URL | MD5 or SHA-256 of the URL, take the first 7 base62 characters | Needs a read before every write to detect collisions, which doubles the write path and is still racy under concurrency. Also makes the same URL always produce the same code, which leaks that somebody else shortened it. |
| One global counter | Increment a row or a Redis INCR, encode the result | Simple and collision-free, but it is a single point of failure on the write path and produces perfectly sequential codes that anyone can enumerate. |
| Counter ranges (chosen) | Each service instance claims a block of a million ids from a coordination store, then hands them out from memory | One coordination call per million links, no collisions, no read before write, and an instance that dies simply burns its remaining block. |
| Pre-generated key pool | A separate service generates random unused codes in advance and hands them out in batches | Also collision-free and gives non-sequential codes for free, at the cost of an extra service and a table of unused keys to keep stocked. |
Counter ranges win on simplicity, and the gaps left by dying instances are irrelevant against a trillion-code space. The one thing they do badly is hide the sequence: 000001A, 000001B, 000001C is an invitation to walk the entire dataset. Fix it by scrambling the id before encoding, with a reversible permutation such as multiplying by a large odd constant modulo 627 and taking the result, or by XOR-folding a per-range secret into it. The codes stay unique and collision-free, but consecutive ids land far apart.
sequenceDiagram autonumber participant C as Client participant A as API instance participant Z as Range service participant D as Store C->>A: POST /links, url and Idempotency-Key 7f3a A->>D: has key 7f3a been seen? D-->>A: no Note over A: local block 41,000,000 to 41,999,999 has ids left A->>A: take next id, scramble, encode as base62 A->>D: conditional put, code must not exist D-->>A: created A-->>C: 201 with the short URL Note over A,Z: when the block empties, claim the next one from the range service
Custom aliases live in the same keyspace and break its tidiness: they are user-chosen, must be unique, and must never collide with a generated code. Two rules keep it manageable. Require aliases to be at least eight characters or to contain a character the generator never emits, so the two spaces cannot overlap. And take the uniqueness check as a conditional write on the same table, so two people racing for the same alias resolve at the store rather than in application code.
A short code is not a secret. Seven base62 characters can be brute-forced by anyone willing to spend requests, and there is no rate limit that distinguishes a scan from a viral link. If a link points at something private, the shortener must not be the thing protecting it — the target needs its own authorization.
- Base62
- Positional encoding over
0-9a-zA-Z. Seven characters address 3.52 trillion values in a URL-safe form with no escaping. - Birthday bound
- Random values start colliding at roughly the square root of the keyspace, which is why a keyspace must be far larger than the number of items you expect.
- Range allocation
- Handing a server a block of ids it may use without further coordination. Trades id gaps for the removal of a bottleneck.
Storing the mapping
The store has to do one thing well — return a 500-byte row given a 7-character key — at eleven thousand reads a second, over several terabytes, cheaply.
| Option | Fit | Verdict |
|---|---|---|
| Postgres, single primary | Handles 350 writes and 11.6k reads a second with replicas, up to a few terabytes | Works for years and is the right first answer; the B-tree on an effectively random key is the part that degrades as the table grows past memory |
| DynamoDB or Cassandra | Partition by code, single-digit millisecond gets, scales by adding nodes | The chosen shape at this size: the access pattern is exactly what these stores are for, and TTL is built in |
| Redis as the store | Fast, but every row in memory | No: 9 TB of mostly-cold rows in RAM is an absurd bill. Redis is the cache, not the store |
| Object storage | Cheapest per byte | No: tens of milliseconds per get and no conditional writes |
Sharding is by hash of the code, which is already uniformly distributed, so there is no hot-shard problem and no skew to engineer around — a rare luxury that exists only because the key is generated rather than natural. Replication factor three, with reads allowed from any replica: the row is immutable, so a replica that is a second behind cannot return a wrong answer, only a not-found for a link created moments ago. That is worth a sentence in the design, because it is the reason this system can be eventually consistent without being incorrect.
- DynamoDB fits this workload almost too neatly: single-key access, on-demand capacity for the spiky read path, and native TTL for expiry.
- Cassandra is the choice when writes must be accepted in several regions at once, since its leaderless quorum model does that natively.
- A single Postgres runs a shortener with tens of millions of links without complaint, and most shorteners never outgrow it.
The read path: where the system actually lives
Ninety-nine percent of the traffic is one request: GET /{code}, answered with a redirect. Everything about serving it is about not touching the store.
flowchart TD U["browser follows the short link"] --> E["edge point of presence"] E --> W["redirect worker, stateless"] W --> C["regional cache: code to URL"] C -- "hit, about 98%" --> R["302 to the target"] C -- "miss" --> K[["key-value store, sharded by code"]] K --> F["fill the cache, then redirect"] W --> Q[["click event to a log"]]
The cache is the whole trick, and it works because link popularity is Zipfian: a tiny fraction of links carry most traffic, and a link that has just been clicked is overwhelmingly likely to be clicked again. From the sizing above, caching the top one percent — 180 million entries at about 200 bytes of key, value and overhead — is roughly 36 GB, which is a handful of cache nodes and answers around 98 percent of requests. Doubling the cache buys perhaps one more point of hit rate; this is the shape of every Zipfian cache, and it is why the right size is found by measurement rather than by ambition.
Two mechanics matter more than the size. Negative caching: a code that does not exist must also be cached, briefly, or a scanning attacker turns every probe into a store read — the cheapest possible denial of service. And request coalescing: when a link goes viral, thousands of requests miss the cache in the same millisecond and all go to the store for the same key; the worker should let one through and have the others wait on its result.
The redirect status code is a product decision disguised as a technical one:
| Code | Browser behaviour | Consequence |
|---|---|---|
| 301 Moved Permanently | Cached by the browser, often indefinitely | Fastest for the user and cheapest to serve, but the second and every later click never reaches you: no analytics, and the link can never be retargeted or revoked |
| 302 Found (chosen) | Not cached by default | Every click is seen and counted, deletion takes effect immediately, at the cost of serving every request |
| 307 Temporary Redirect | Like 302 but preserves the method | Correct for non-GET requests, which a shortener does not have |
Since clicks are usually the product, 302 with an explicit short Cache-Control: private, max-age=0 is the answer. If analytics are not needed, 301 makes the system an order of magnitude cheaper, which is worth saying out loud rather than treating the choice as obvious.
A cache node restarting comes back empty, and every request for its share of the keyspace goes to the store at once — a store provisioned for two percent of traffic suddenly takes all of it. Warm a node from the top keys before putting it back in rotation, spread the keyspace so one node is a small fraction of it, and keep enough store headroom to survive the gap.
- Cloudflare Workers with KV resolve the whole redirect at the edge, which removes the origin from the common path entirely and is the modern shape of this design.
- Bitly serves redirects from a caching tier in front of its key-value store, with the origin sized for misses rather than for traffic.
- t.co answers from Twitter's own edge, where the redirect is a few hundred microseconds of work against an in-memory map.
Counting clicks without slowing the redirect
The one rule is that nothing on the analytics path may block the redirect. A counter incremented inline puts a write on the hot path for every read, turns the hottest links into the hottest write keys, and makes the redirect's availability depend on the analytics store.
flowchart TD W["redirect worker"] -->|"fire and forget"| L[["click log, partitioned by code"]] L --> S["stream job, one-minute windows"] S --> A[["per code per day counters"]] S --> H[["HyperLogLog sketch per code"]] A --> D["stats API and dashboard"] H --> D
The volume is real: a billion events a day at roughly 200 bytes each is about 200 GB of raw events a day. Three things keep that affordable. Aggregate early — a stream job rolls events into per-code per-day counters, and a billion events become a few tens of millions of counter updates. Sketch the expensive questions — unique visitors per link is a distinct-count, which is enormous to compute exactly and nearly free with a HyperLogLog sketch at about two percent error and twelve kilobytes per link. Expire the raw tier — keep raw events for a week for debugging and the aggregates forever.
Because the pipeline is at-least-once, an occasional duplicate click is counted twice. For a metric that exists to show a trend, that is the right trade against the cost of exactly-once counting; the messaging article covers why that cost is what it is.
- Kafka into Flink into ClickHouse is the common shape: a partitioned log, windowed aggregation, and a columnar store that answers the dashboard's range queries in milliseconds.
- Redis HyperLogLog makes unique-visitor counts a one-line operation at a fixed 12 KB per key, which is why so many analytics features are built on it.
- Bitly's public metrics are aggregates over exactly this kind of pipeline, which is why they update within minutes rather than instantly.
Expiry, deletion and the cache that remembers
Links expire, get deleted, or are taken down for abuse. Each is easy in the store and awkward in the cache, and the awkwardness is the whole of this section.
In the store, expiry is a TTL on the row, which DynamoDB and Cassandra both reclaim in the background, and deletion is a tombstone that a later compaction removes. Neither costs anything on the read path. The problem is the copy of the row sitting in a cache in another continent, which knows nothing about either.
stateDiagram-v2 [*] --> Active: created Active --> Expired: expires_at passes Active --> Deleted: owner deletes Active --> Blocked: abuse report upheld Expired --> [*]: reclaimed by TTL Deleted --> [*]: tombstone compacted Blocked --> Active: appeal upheld
Three rules handle it. Give every cache entry a TTL, five to fifteen minutes, so the worst-case staleness is bounded even when an invalidation is lost — this is the safety net that makes the other two optional rather than critical. Publish invalidations on a topic that every cache tier subscribes to, so the common case is fast. And never reuse a code: an expired or deleted code is retired permanently, because handing it to a new link means the millions of places the old link was posted now point somewhere unexpected. The keyspace is large enough that retiring codes forever costs nothing.
When a code is gone, the answer is 404 or 410 with a page explaining it, not a redirect to a homepage. A wrong redirect is the one failure mode this system must never have.
Abuse: the part that is not about scale
A shortener hides the destination, which makes it useful for sharing and equally useful for phishing. Any design that ignores this is incomplete, because in practice abuse handling is most of the operational work.
On creation, check the target against a reputation service and a local block list, reject known-bad domains, and rate limit creation per account and per IP so a campaign cannot mint a hundred thousand links in an hour. Checking synchronously adds latency to a path that can afford it; the alternative is to accept the link and scan asynchronously, disabling it within seconds if it fails.
On redirect, re-check the target against the block list, because a domain that was clean at creation may be compromised a month later — this is why the block list is consulted at read time and not only at write time. For a target that has been flagged since, serve an interstitial warning rather than the redirect.
Against enumeration, the defence is the keyspace plus negative caching plus rate limiting per client on misses. A scanner is easy to spot because its miss rate is near 100 percent while a normal client's is near zero, which makes miss rate a better throttling signal than request rate — the reliability article covers the mechanics of the limiter itself.
- Google Safe Browsing is the reputation service most shorteners call, and browsers consult it independently, which is a second line of defence the design gets for free.
- Bitly and t.co both run link scanning and will disable a short link after creation, which is why the state machine above has a Blocked state at all.
- Several public shorteners have been shut down over abuse rather than cost, which is the honest argument for building this in from the start.
Going global
Redirect latency is dominated by distance, so the service has to run near the user. The immutability of the mapping is what makes that easy.
Reads are trivial to distribute: an anycast address puts the user on the nearest point of presence, each region holds its own cache, and a miss falls back to a regional replica of the store. Because a row never changes after creation, a replica that is seconds behind can only be missing a very new link, never wrong about an old one — so cross-region replication can be asynchronous, which is what keeps it cheap and fast.
Writes are the part that needs a decision. Two workable answers: route every create to a single home region, accepting a hundred milliseconds of extra latency on a call that can afford it; or accept writes anywhere with a globally replicated store, which is fine for generated codes because ranges guarantee uniqueness without coordination. Custom aliases are the exception in both cases, since two regions can accept the same alias simultaneously — those need either the home region or a conditional write against a globally consistent store.
flowchart TD U["user in Frankfurt"] --> A["anycast, nearest point of presence"] A --> EC["EU cache"] EC -- "miss" --> ER["EU replica of the store"] ER -- "not found, very new link" --> HR["home region store"] W["create request"] --> HW["home region write"] HW --> ER HW --> AR["US and APAC replicas"]
When a region loses its store entirely, the right behaviour is to keep serving from cache and fail only on misses. A stale-but-correct redirect is strictly better than an error here, which is an explicit choice of availability over consistency — the correct one for this system, and the wrong one for a payment system.
What breaks first
The design is finished when the ladder of bottlenecks is known and the next rung is planned.
- Cache memory. The first thing to run out. As the corpus grows, the hot set grows with it and the hit rate slides, which shows up as store load rather than as user-visible latency. Watch hit rate, not memory used.
- Store read throughput. Once the hit rate slips, the store takes the difference — a drop from 98 to 95 percent hit rate more than doubles its load. Adding shards is the answer, and it is easy because the key is uniformly distributed.
- The click pipeline. A billion events a day grows linearly with redirects and has none of the cache's leverage. It scales by adding partitions and consumers, and its lag is the metric to alert on.
- The range service. Almost never: it is touched once per million codes. It is on the list only because it is the one component whose failure stops creation entirely, so it needs a replicated store and a large enough block size that a brief outage is invisible.
At ten times the traffic — ten billion redirects a day — the shape does not change. More edge locations rather than more origin capacity, more cache nodes with a smaller share of the keyspace each, more store shards, more click-log partitions. Nothing in this design has a step change in it, which is the real test of whether the shape was right.
Recap
- The numbers first: 10 million writes and 1 billion reads a day is 350 writes and 35,000 reads a second at peak, 1.8 TB a year, and a hundred-to-one read ratio that makes this a caching problem.
- Immutability is the keystone decision. A mapping that never changes can be cached indefinitely, replicated asynchronously and served from a stale replica without ever being wrong.
- The only access pattern that matters is code to URL, which is a pure key-value lookup; owner listings and click stats belong in separate tables and separate stores.
- Seven base62 characters give 3.52 trillion codes, about 960 years of headroom. Six is wrong because of the birthday bound, not because of exhaustion.
- Counter ranges beat hashing (no read before write, no collisions) and a global counter (no bottleneck). Scramble the id before encoding so codes are not enumerable.
- Custom aliases share the keyspace, so separate them by length or alphabet and settle races with a conditional write.
- The cache answers about 98 percent of redirects from roughly 36 GB. Negative caching blocks the cheapest denial of service, and request coalescing handles a link going viral.
- 302 keeps every click and allows revocation; 301 is faster and cheaper but the second click is never seen. Pick deliberately.
- Clicks leave by a separate path: fire-and-forget event, windowed aggregation, HyperLogLog for unique counts. Nothing on the analytics path may block a redirect.
- Expiry and deletion are easy in the store and hard in the cache: bound staleness with a short cache TTL, publish invalidations, and never reuse a retired code.
- Abuse handling is not optional — scan targets at creation, re-check the block list at redirect time, and throttle on miss rate to catch scanners.
- Reads go global easily and writes go to a home region. On a regional store failure, serve stale from cache: availability beats consistency for a redirect.
- The bottleneck ladder is cache hit rate, then store throughput, then the click pipeline. None of the rungs needs a different architecture.
Questions
Try answering each one out loud before opening it. Lead with the one-line answer, then the reasoning, then one detail from a real system.
Size a URL shortener taking 10 million new links and a billion redirects a day.
About 115 writes and 11,600 reads a second on average, roughly 350 and 35,000 at peak, with a 500-byte row giving 5 GB a day, 1.8 TB a year and around 5.5 TB once replicated three times.
- A day is 86,400 seconds, so a billion a day is about 11.6k per second, and peak runs two to three times average.
- The write rate is trivially small — a single database handles it — so the design is entirely about the read path.
- Five years is about 18 billion rows, of which only a few hundred million are ever read again.
The hundred-to-one ratio is the single number that determines the architecture: it makes this a caching problem with a durable store attached.
Why is it worth deciding early that a link's target can never be edited?
Because immutability means a cached copy can never be wrong, which removes invalidation from the hot path, allows asynchronous cross-region replication with no conflict resolution, and makes reading from a lagging replica safe.
- An editable target forces cache invalidation on every write and a version on every row.
- It also forces read-after-write consistency, which is what would otherwise stop replicas from serving reads.
- The only staleness left is a link created seconds ago not yet visible everywhere, which is a not-found rather than a wrong answer.
It is the clearest example of a requirement exclusion being worth more than any optimisation applied later.
Why seven base62 characters rather than six?
Six gives 56.8 billion codes, which at ten million a day is only about fifteen years, and random generation into that space starts colliding at its square root — a few hundred thousand links — long before it fills.
- Seven gives 3.52 trillion, roughly 960 years of headroom at the same rate.
- Base62 is chosen over hex for density and over base64 because two of base64's characters need URL escaping.
- Going to eight costs a character in every shared link for headroom nobody will use.
The birthday bound is the part people miss: keyspace exhaustion is not the binding constraint, collision probability is.
Compare the ways of generating the code.
Hashing needs no coordination but requires a read before every write to detect collisions; a global counter is collision-free but is a single point of failure and fully enumerable; range allocation gives each server a block of a million ids, which removes both problems at the cost of gaps.
- Hashing also makes the same URL always produce the same code, which leaks that someone else shortened it.
- A pre-generated key pool is the fourth option and gives non-sequential codes for free, at the cost of another service.
- Gaps from a dying server are irrelevant against a trillion-code keyspace.
Whichever is chosen, a conditional write on insert is worth keeping as a cheap guard against a custom alias having claimed the code.
Sequential codes are easy to enumerate. How do you keep range allocation and still avoid that?
Scramble the id before encoding with a reversible permutation — multiply by a large odd constant modulo the keyspace, or fold in a secret with XOR — so consecutive ids produce codes that are far apart while remaining unique.
- The permutation must be a bijection, or uniqueness is lost and collisions come back.
- It is obfuscation, not encryption: it defeats casual walking of the keyspace, not a determined attacker.
- The alternative is a pre-generated pool of random codes, which gives the same property structurally.
Either way, a short code must never be treated as an access control mechanism — the target needs its own authorization.
Which store would you choose, and why not Redis?
A key-value store partitioned by code — DynamoDB or Cassandra — because the dominant access pattern is a single-key get with no joins, ranges or ordering, and both offer native TTL for expiry. Redis is rejected as the store because it holds everything in memory and most of these rows are cold.
- Nine terabytes of mostly-never-read rows in RAM is an absurd bill next to the same data on disk.
- A single Postgres is genuinely the right answer up to a few terabytes, and most shorteners never exceed that.
- Object storage is too slow per get and offers no conditional writes.
Sharding is unusually painless here because a generated code is already uniformly distributed, so there is no skew to design around.
Why does the cache work so well, and how large should it be?
Because link popularity is Zipfian: roughly one percent of links carry about ninety percent of redirects, so caching about 180 million entries at around 200 bytes each — some 36 GB — answers around 98 percent of requests.
- Recency compounds it: a link just clicked is very likely to be clicked again immediately.
- Past the knee of the curve, doubling the cache buys roughly one more point of hit rate.
- Hit rate is the metric to watch, because a slide from 98 to 95 percent more than doubles store load.
The right size is found by measuring the curve on real traffic, not by picking a fraction of the corpus in advance.
Why cache negative lookups, and what attack does it prevent?
Because without it every request for a code that does not exist becomes a store read, so an attacker generating random codes turns cheap requests into expensive database work — the least effort denial of service available against this design.
- Negative entries need only a short TTL, since a code that does not exist may be created shortly after.
- Miss rate is also the signal that identifies a scanner: near 100 percent for an attacker, near zero for a normal client.
- Throttling on miss rate is therefore more precise than throttling on request rate.
This is the same asymmetry that makes cache penetration a standard concern for any read-through cache.
A link goes viral and a hundred thousand requests for one code miss the cache at once. What happens?
Without protection, all of them hit the store for the same key simultaneously and can overwhelm a single shard; the fix is request coalescing, where one request fetches and the rest wait on its result.
- The store is provisioned for about two percent of traffic, so a stampede on one key is far outside its budget.
- Coalescing is per node, so a hundred nodes still produce a hundred reads rather than a hundred thousand.
- A short negative or positive TTL plus jitter stops all nodes expiring the same key at the same instant.
This is the cache stampede problem from the storage article, and a viral link is its most natural trigger.
301 or 302, and what does the choice cost?
302, because a 301 is cached by the browser indefinitely and every subsequent click bypasses the service, which removes click analytics and makes deletion or retargeting impossible for anyone who has already visited.
- 301 is genuinely faster for users and an order of magnitude cheaper to serve.
- With 302 the response still carries an explicit no-store cache header, or intermediaries will cache it anyway.
- 307 preserves the request method, which a shortener never needs since it only handles GET.
It is a rare case where the slower option is chosen for a product reason, and worth stating as such rather than asserting one is correct.
How do you count a billion clicks a day without slowing redirects?
Emit a fire-and-forget event to a partitioned log after the redirect has been returned, then aggregate in a stream job into per-code per-day counters, with a HyperLogLog sketch for unique visitors.
- An inline counter increment puts a write on the hot read path and makes the most popular links the hottest write keys.
- Aggregation turns a billion events into tens of millions of counter updates, and raw events are kept only for about a week.
- Unique counts are a distinct-count problem, which a sketch answers at roughly two percent error and about 12 KB per link.
Delivery is at-least-once, so an occasional double count is accepted — the correct trade for a metric that exists to show a trend.
A user deletes a link. Why is that harder than it sounds?
Because the store deletion is trivial but copies of the row are sitting in caches in several regions, none of which know about it, so the link keeps resolving until those entries expire.
- Publish an invalidation on a topic every cache tier subscribes to for the fast path.
- Give every cache entry a short TTL — five to fifteen minutes — so a lost invalidation still bounds the staleness.
- Answer with 404 or 410 once it is gone, never a redirect to a homepage.
The related rule is that a retired code is never reissued, because the old link still exists in every place it was ever posted.
How do you stop the service being used for phishing?
Check the target against a reputation service and a block list at creation, rate limit creation per account and per IP, and re-check the block list at redirect time because a domain that was clean last month may be compromised now.
- Scanning can be synchronous on create, which can afford the latency, or asynchronous with the link disabled within seconds.
- A flagged target should serve an interstitial warning rather than redirect silently.
- Browsers consult Safe Browsing independently, which is a second line of defence the design gets for free.
In practice abuse handling is most of the operational work on a public shortener, and several have closed over it rather than over cost.
How would you serve redirects globally?
Anycast to the nearest point of presence, a cache per region, and an asynchronously replicated store behind it — which is safe precisely because the mapping is immutable, so a lagging replica can only be missing a new link, never wrong about an old one.
- Writes go to a home region, or anywhere if generated codes are range-allocated so uniqueness needs no coordination.
- Custom aliases are the exception, since two regions could accept the same alias at once, so they need the home region or a globally consistent conditional write.
- A very new link may miss locally and fall through to the home region, which is rare enough to be acceptable.
Edge platforms collapse this further by resolving the redirect in the point of presence itself, removing the origin from the common path.
A whole region loses its copy of the store. What should the redirect service do?
Keep serving everything the cache holds and fail only on misses, because for a redirect a possibly stale but correct answer is strictly better than an error.
- The mapping is immutable, so a cached entry cannot be wrong, only missing a deletion.
- This is an explicit choice of availability over consistency and should be stated as one.
- Misses can fall through to another region rather than failing outright, trading latency for availability.
The same choice would be wrong for a payment system, which is why it belongs in the design document rather than being assumed.
What breaks first, and what breaks at ten times the traffic?
Cache hit rate degrades first as the corpus grows, which pushes load onto the store; after that the store's read throughput, then the click pipeline, which scales linearly with redirects and has none of the cache's leverage.
- A slide from 98 to 95 percent hit rate more than doubles store load, so hit rate is the leading indicator.
- Ten times the traffic means more edge locations and cache nodes, more store shards and more log partitions — no architectural change.
- The range service is the only component whose failure stops creation, so it gets a replicated store and large blocks.
That the ladder contains no step change is the real evidence that the shape of the design is right.