#Design a URL shortener
AddedA URL shortener turns https://shop.example.com/campaigns/2026/spring?utm_source=newsletter&utm_medium=email into https://sho.rt/m0JvKvd, and when anyone opens the short link it answers with a redirect to the long one. It looks like a toy, which is exactly why interviewers like it: the functionality fits on a sticky note, so the whole 45 minutes goes on the parts that matter at scale. How do you hand out billions of short codes that never collide, without every server coordinating on every request? And how do you answer ten times as many redirects as you create links, in a few milliseconds, from anywhere?
A good mental model is a coat check. You hand over a coat (the long URL) and get a ticket (the code). Tickets must be unique, short enough to read out, and ideally not guessable, because whoever holds ticket 41 shouldn't be able to collect coat 42. Issuing tickets is the rare operation; looking a ticket up and fetching the coat is what happens all evening. So you make the lookup brutally fast (a cache in front of a key-value store), and you make ticket issuing cheap by giving each attendant a whole book of pre-numbered tickets instead of making them ask the manager for each one.
Everything else in the design follows from two numbers you will calculate below: about 1,200 new links a second against about 12,000 redirects a second, and 182 billion links after five years. The second number decides the code length (7 characters); the first pair decides that the read path gets a cache and the write path gets counter ranges.
The heart of the design is the code generator. Here are the two serious options side by side: a counter handed out in ranges and encoded in base62, and a hash of the URL with a collision check.
import { createHash } from "node:crypto";
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const SPACE = 62n ** 7n; // every 7-character code: 3,521,614,606,208
const base62 = (n, width = 7) => {
let s = "";
for (; n > 0n; n /= 62n) s = ALPHABET[Number(n % 62n)] + s;
return s.padStart(width, "0");
};
// Approach 1: a counter, handed out in ranges. One small table (or ZooKeeper node) owns "next free id";
// each app server grabs 1,000 ids at a time, so it talks to the allocator once per 1,000 links.
let nextFree = 1n;
const grabRange = (size = 1000n) => { const start = nextFree; nextFree += size; return { next: start, end: start + size }; };
const servers = ["app-1", "app-2", "app-3"].map((name) => ({ name, range: grabRange() }));
// Scramble the id so codes aren't guessable: three rounds of "multiply by a number coprime with 62^7,
// add a constant, reverse the 7 digits". Each step is a bijection, so two ids can never share a code.
const reverse = (x) => { let y = 0n; for (let i = 0; i < 7; i++, x /= 62n) y = y * 62n + (x % 62n); return y; };
const scramble = (x) => { for (const c of [11n, 23n, 37n]) x = reverse((x * 2_654_435_761n + c) % SPACE); return x; };
function counterCode(server) {
if (server.range.next === server.range.end) server.range = grabRange();
const id = server.range.next++;
return { id, code: base62(scramble(id)) };
}
for (const s of servers) {
const a = counterCode(s), b = counterCode(s);
console.log(`${s.name}: id ${a.id} -> ${a.code}, id ${b.id} -> ${b.code}`);
}
// Approach 2: hash the long URL and keep the first few characters, checking the database for a clash.
// To make clashes visible, use 3-character codes (62^3 = 238,328 slots) and 20,000 URLs.
const hashCode = (url, salt, width) => {
const n = BigInt("0x" + createHash("sha256").update(url + salt).digest("hex").slice(0, 16));
return base62(n % 62n ** BigInt(width), width);
};
const taken = new Map(); // code -> url, standing in for the unique index on short_code
let clashes = 0, extraLookups = 0;
for (let i = 0; i < 20_000; i++) {
const url = `https://example.com/article/${i}`;
for (let salt = 0; ; salt++) {
const code = hashCode(url, salt ? `#${salt}` : "", 3);
extraLookups += salt > 0;
if (!taken.has(code)) { taken.set(code, url); break; }
clashes++;
}
}
const m = 62 ** 3, k = 20_000;
console.log(`hash, 3 chars: ${clashes} clashes over ${k} inserts (${extraLookups} retries)`);
console.log(`birthday estimate k^2/2m = ${Math.round(k * k / (2 * m))}`);
// Same estimate at real scale: 7 characters, 182.5 billion links (100M a day for 5 years).
const K = 182.5e9, M = 62 ** 7;
console.log(`7 chars, ${K / 1e9}B links: ~${(K * K / (2 * M) / 1e9).toFixed(1)}B pairs would clash without a check`);
console.log(`chance the next insert clashes when full: ${(K / M * 100).toFixed(1)}%`);app-1: id 1 -> m0JvKvd, id 2 -> ax0adZ8
app-2: id 1001 -> jgd4x8T, id 1002 -> 6MMlVGs
app-3: id 2001 -> TRxm3YY, id 2002 -> HnQhBVE
hash, 3 chars: 942 clashes over 20000 inserts (942 retries)
birthday estimate k^2/2m = 839
7 chars, 182.5B links: ~4.7B pairs would clash without a check
chance the next insert clashes when full: 5.2%base62writes a number in 62 symbols (0-9,a-z,A-Z), padded to 7 characters. Seven characters give62^7= 3,521,614,606,208 codes, which is the space everything else lives in (the encoder itself is explained in Base62 and short codes).grabRangeis the only shared state: one row (or a ZooKeeper node) holding the next free id. Each app server takes 1,000 ids at a time, soapp-1owns 1–1,000,app-21,001–2,000 andapp-32,001–3,000. After that, each server mints codes from memory with no network call at all, and two servers can never hand out the same id because their ranges don't overlap.- A raw counter would give codes like
0000001,0000002: anyone could walk through every link ever made.scramblefixes that with three rounds of "multiply by a number coprime with62^7, add a constant, reverse the digits". Every step is a bijection on the 3.5 trillion codes (it can be undone), so distinct ids still give distinct codes, and neighbouring ids come out unrelated: id 1 ism0JvKvd, id 2 isax0adZ8. It isn't encryption (a determined attacker with many samples could undo it); if links must be unguessable, use a keyed format-preserving cipher or random codes. - The hash approach needs no allocator: SHA-256 the URL, keep a few base62 characters, and insert with the code as a unique key. To make clashes visible, the model uses only 3 characters (238,328 slots) for 20,000 URLs. It hit 942 clashes, each needing a salted retry and an extra lookup; the birthday estimate
k²/2mpredicts 839 first-try clashes, and retries that clash again plus randomness make up the rest. - At real scale the same maths says 7-character hashes over 182.5 billion links would produce about 4.7 billion clashing pairs if you didn't check, and once the table holds 182.5 billion links, each new insert has a 5.2% chance of landing on a taken code. So "hash and truncate" is never safe on its own: it always needs the check-and-retry loop, and that loop is a read on every write that gets more expensive as the table fills.
- That's the trade in one run: counter ranges cost one tiny coordination call per 1,000 links and can never collide; hashing costs a uniqueness check on every insert but gives the same code for the same URL (free deduplication) with no allocator to run. The design below uses counter ranges.
Requirements
Ask before drawing. These are the answers this design assumes; say them out loud and write them in a corner of the board.
Functional:
- Create a short link for a long URL; optionally with a custom alias (
sho.rt/spring-sale) and an expiry date. - Redirect: opening the short link sends the browser to the long URL.
- Click analytics for the link's owner: clicks per day, referrers, countries. Minutes of delay are fine.
- Owners can disable or delete a link (abuse reports need this too).
Non-functional:
- Redirects are the product. A broken short link is broken in every email, tweet and printed poster that contains it, so the redirect path targets 99.99% availability (about 52 minutes of downtime a year, see Availability, SLAs, SLOs and SLIs) and a p99 well under 50 ms at the server.
- A short code must never point to the wrong URL: durability and uniqueness matter more than creation latency.
- Codes should not be enumerable (people shorten private document links).
- Scale: design for 100 million new links a day. Bitly, the best-known shortener, said in a February 2026 press release that it handles "hundreds of millions" of links and QR codes a month, so 100 million a day (3 billion a month) is roughly ten times that, a comfortable interview target.
Out of scope unless asked: user accounts and billing, QR codes, link-in-bio pages, A/B redirects.
Estimates
The assumptions: 100M new links a day, 10 redirects per link created, a peak of 3× the average, 500 bytes per stored link (code, long URL averaging a few hundred bytes, owner, timestamps, indexes), links kept 5 years, and a cache holding the hottest 20% of the last 30 days of links.
// Assumptions (stated in the text): 100M new links a day, 10 redirects per link created,
// peak = 3x average, 500 bytes per stored link, keep links 5 years, the cache holds 20% of the last 30 days of links.
const DAY = 86_400;
const writesPerDay = 100e6;
const readsPerDay = writesPerDay * 10;
const peak = 3;
const bytesPerLink = 500;
const years = 5;
const writeQps = writesPerDay / DAY;
const readQps = readsPerDay / DAY;
const links = writesPerDay * 365 * years;
const storageTB = (links * bytesPerLink) / 1e12;
const fmt = (n) => Math.round(n).toLocaleString("en-US");
console.log(`writes: ${fmt(writeQps)}/s average, ${fmt(writeQps * peak)}/s peak`);
console.log(`redirects: ${fmt(readQps)}/s average, ${fmt(readQps * peak)}/s peak`);
console.log(`links after ${years} years: ${(links / 1e9).toFixed(1)} billion`);
console.log(`storage: ${storageTB.toFixed(1)} TB (x3 replicas = ${(storageTB * 3).toFixed(0)} TB)`);
for (const len of [6, 7, 8]) {
const space = 62 ** len;
const used = links / space;
console.log(`${len} chars: ${(space / 1e9).toFixed(1)}B codes, ${used > 1 ? "too few" : `${(used * 100).toFixed(1)}% used after ${years} years`}`);
}
// Cache: the hottest 20% of the last 30 days of links (older links are rarely clicked).
const hotLinks = writesPerDay * 30 * 0.2;
console.log(`cache for ~${(hotLinks / 1e6).toFixed(0)}M hot links: ${((hotLinks * bytesPerLink) / 1e9).toFixed(0)} GB`);
// Egress: a redirect response is ~500 bytes of headers.
console.log(`redirect egress at peak: ${((readQps * peak * 500 * 8) / 1e6).toFixed(0)} Mbit/s`);writes: 1,157/s average, 3,472/s peak
redirects: 11,574/s average, 34,722/s peak
links after 5 years: 182.5 billion
storage: 91.3 TB (x3 replicas = 274 TB)
6 chars: 56.8B codes, too few
7 chars: 3521.6B codes, 5.2% used after 5 years
8 chars: 218340.1B codes, 0.1% used after 5 years
cache for ~600M hot links: 300 GB
redirect egress at peak: 139 Mbit/s- 100M writes over 86,400 seconds is 1,157 writes/s, 3,472/s at peak. One well-sized database primary handles that comfortably: the write rate is not what forces sharding. Size is: step 4 below comes to about 91 TB, which no single primary holds, so the store is sharded by code from the start.
- Redirects are ten times that: 11,574/s average, 34,722/s at peak. This is the number the cache exists for: 35,000 reads a second against a disk-backed store is possible but expensive, against Redis it is a handful of nodes.
- Five years is 182.5 billion links. Six characters give only 56.8 billion codes ("too few"); seven characters give 3.5 trillion, of which you would use 5.2%. Eight is wasteful (0.1% used) and makes every link longer forever. That single line is how you justify "7 characters" in the interview.
- Storage is 91 TB, 274 TB with three replicas: far beyond one machine, and this is the number that decides sharding. The links store is a sharded key-value or wide-column store, or a sharded relational database.
- The cache for 600 million hot links is about 300 GB: a small Redis cluster. The deep dive below checks how much of the traffic a cache of a given size actually absorbs.
- Redirect responses are tiny, so egress is only about 139 Mbit/s at peak. Bandwidth is not the constraint here; request rate and latency are.
API
Three public endpoints and one redirect. Creation is a normal JSON API; the redirect is a bare GET on the short domain so it works from any browser.
POST /api/v1/links
Idempotency-Key: 6f1c… (a retried create must not mint a second code)
{ "url": "https://shop.example.com/…", "alias": "spring-sale", "expiresAt": "2026-12-31T00:00:00Z" }
-> 201 Created { "code": "m0JvKvd", "shortUrl": "https://sho.rt/m0JvKvd", "expiresAt": "…" }
-> 409 Conflict if the alias is taken; 400 if the URL is malformed or on a blocklist
GET /{code}
-> 302 Found Location: https://shop.example.com/… Cache-Control: private, max-age=90
-> 404 Not Found if unknown; 410 Gone if disabled or expired
GET /api/v1/links/{code}/stats?from=2026-10-01&to=2026-10-24 -> clicks per day, top referrers, countries
DELETE /api/v1/links/{code} -> 204 (soft delete: the code is never reused)The Idempotency-Key header makes a timed-out create safe to retry, as explained in Idempotency keys for APIs. The choice between 301 and 302 on the redirect is one of the deep dives below, because it decides whether you can count clicks at all.
Data model
The access pattern is almost entirely point lookups by code: "give me the URL for m0JvKvd". There are no joins on the hot path and no range scans. That favours a key-value or wide-column store partitioned by code (DynamoDB, Cassandra) or a relational database sharded by a hash of the code. Here is the logical schema in SQL:
CREATE TABLE links (
code varchar(16) PRIMARY KEY, -- 7 chars generated, longer for custom aliases
long_url text NOT NULL,
owner_id bigint, -- null for anonymous links
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
status smallint NOT NULL DEFAULT 0 -- 0 active, 1 disabled, 2 deleted
);
CREATE INDEX links_by_owner ON links (owner_id, created_at DESC); -- "my links" page
CREATE TABLE id_ranges ( -- the range allocator: one row
name text PRIMARY KEY,
next_id bigint NOT NULL
);
-- grab 1,000 ids atomically:
-- UPDATE id_ranges SET next_id = next_id + 1000 WHERE name = 'links' RETURNING next_id - 1000;
CREATE TABLE clicks_daily ( -- written by the analytics pipeline, not the redirect path
code varchar(16),
day date,
clicks bigint NOT NULL,
PRIMARY KEY (code, day)
);Two details are worth saying. The links_by_owner index is a secondary index in a sharded system: if links is sharded by code, the owner's links are spread across every shard, so either keep a separate owner-keyed table or accept scatter-gather for that rare page (see Secondary indexes in a sharded system). And deleted codes are never reused: a reused code would silently send old links in old emails to someone else's page.
High-level design
Walk the board in this order:
- Create: the client posts the long URL. The create service validates it (scheme, length, blocklist), takes the next id from its in-memory range (grabbing a new range from the allocator every 1,000 links), scrambles and encodes it, and writes
code → long_urlto the links store with a conditional "insert if absent". It returns the short URL. With a custom alias, the code is the alias and the conditional insert is what detects "taken". - Redirect: the browser requests
GET /m0JvKvd. The redirect service looks the code up in Redis; on a hit (most requests, see below) it answers302immediately. On a miss it reads the links store, fills the cache, and answers. - Analytics: after answering, the redirect service publishes a click event (code, time, referrer, country from the IP, user agent) to Kafka without waiting for it. A stream job aggregates clicks per code per day into
clicks_daily; raw events go to object storage for anything else. - Both services are stateless, so they scale horizontally behind the load balancer; all state lives in the allocator, the cache, the store and Kafka.
There are three families of approach. The model above ran two of them; this table puts all three next to each other.
| Approach | How it works | Collisions | Coordination | Weakness |
|---|---|---|---|---|
| Counter + base62 | Global counter, encode the number | Impossible | One call per range (1,000 ids) | Sequential unless scrambled; allocator must be highly available |
| Hash + truncate | base62(sha256(url))[0..7] | Grow with table size; 5.2% per insert when full | None, but a uniqueness check per insert | Read before every write; same URL from two users shares a code |
| Random | 7 random base62 characters | Same maths as hashing | None, check per insert | Same check-and-retry cost; no dedup |
The counter wins for this design because the numbers say so: at 1,157 writes a second, a check-and-retry read on every write is real load on the store, and it gets worse as the store fills; the counter's coordination cost is one allocator call per 1,000 links, about 1.2 allocator calls a second at average load. Ranges also survive server crashes cheaply: if app-2 dies holding ids 1,001–2,000, those unused ids are simply skipped. Out of 3.5 trillion codes, losing a few thousand per crash doesn't matter.
The allocator is now the thing to make reliable. It's one row, so a single strongly consistent database (or a ZooKeeper/etcd counter) is enough; give it a replica with automatic failover and make app servers grab their next range when they are 80% through the current one, so a short allocator outage never blocks creation. Unique ID generation compares this "ticket server" idea with Snowflake-style IDs, which avoid even the allocator by embedding the machine id in the number. Snowflake IDs are 64-bit and time-ordered, which would make codes 11 base62 characters long, so ranges suit a shortener better.
A redirect is a key lookup, so a cache in front of the store is obvious; the real question is how much traffic a cache of a given size absorbs. Click traffic is heavily skewed: a link in a viral post gets millions of clicks, most links get a handful. The usual model for that is a Zipf distribution, where the k-th most popular item gets traffic proportional to 1/k^s. The exponent for your traffic is something you'd measure; here are two plausible values (an assumption, not a measured property of any real shortener):
// How big must the redirect cache be? Clicks follow a power law: a few links get most of the traffic.
// Model: 1,000,000 links, clicks drawn from a Zipf distribution (exponent s), LRU caches of several sizes.
// Seeded PRNG (mulberry32), so every run prints the same numbers.
function mulberry32(a) {
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const LINKS = 1_000_000, CLICKS = 3_000_000;
function zipfSampler(s, rand) {
const cdf = new Float64Array(LINKS);
let sum = 0;
for (let i = 0; i < LINKS; i++) cdf[i] = sum += 1 / (i + 1) ** s;
return () => { // binary search for the first rank whose cumulative weight passes a uniform draw
const u = rand() * sum;
let lo = 0, hi = LINKS - 1;
while (lo < hi) { const mid = (lo + hi) >> 1; if (cdf[mid] < u) lo = mid + 1; else hi = mid; }
return lo;
};
}
function hitRate(capacity, s) {
const next = zipfSampler(s, mulberry32(42));
// LRU as a doubly linked list over typed arrays (link ids are 0..LINKS-1; LINKS is the list's head sentinel).
const prev = new Int32Array(LINKS + 1).fill(-1), nxt = new Int32Array(LINKS + 1).fill(-1);
const H = LINKS; prev[H] = nxt[H] = H;
const unlink = (x) => { nxt[prev[x]] = nxt[x]; prev[nxt[x]] = prev[x]; };
const toFront = (x) => { prev[x] = H; nxt[x] = nxt[H]; prev[nxt[H]] = x; nxt[H] = x; };
let size = 0, hits = 0;
for (let i = 0; i < CLICKS; i++) {
const link = next();
if (prev[link] !== -1) { hits++; unlink(link); }
else if (size >= capacity) { const old = prev[H]; unlink(old); prev[old] = -1; } // evict the tail
else size++;
toFront(link);
}
return hits / CLICKS;
}
for (const s of [0.8, 1.0]) {
const row = [0.001, 0.01, 0.05, 0.2].map((f) => `${(f * 100).toString().padStart(4)}% -> ${(hitRate(LINKS * f, s) * 100).toFixed(1)}%`);
console.log(`s=${s.toFixed(1)} cache size -> hit rate: ${row.join(" ")}`);
}s=0.8 cache size -> hit rate: 0.1% -> 10.0% 1% -> 23.2% 5% -> 38.9% 20% -> 59.0%
s=1.0 cache size -> hit rate: 0.1% -> 40.3% 1% -> 58.5% 5% -> 71.6% 20% -> 82.2%zipfSamplerbuilds a cumulative weight table over a million links (linkkhas weight1/(k+1)^s) and samples by binary search, so popular links come up far more often. The PRNG is seeded, so the hit rates are identical on every run.- The LRU is a doubly linked list in two
Int32Arrays: a hit unlinks the link and puts it at the front; a miss on a full cache evicts the tail (the least recently used link). It's the same policy as theMap-based LRU in Eviction policies: LRU, LFU and TTL, written with arrays because aMapwith millions of deletions got slow enough to break the 30-second budget. - With
s=1.0, caching just 0.1% of links serves 40% of redirects, and 20% of links serves 82%. With the flatters=0.8, the same 20% serves only 59%. The skew of your traffic decides the cache size, which is why "cache 20% and get 80%" is a rule of thumb to check, not a law. - The run also counts cold misses: 3 million clicks over a million links means many links are seen for the first time. A long-running cache that only sees its working set does better than these numbers, so treat them as a floor.
- For the design: at 34,722 redirects a second and an 80% hit rate, the links store sees about 7,000 reads a second at peak instead of 35,000, and the 300 GB cache from the estimates is the 20%-of-recent-links column.
Three more things belong on the redirect path:
- Negative caching. Bots probe random codes. Cache "not found" for a short time (say 60 s) too, or every probe goes to the store. A Bloom filter of existing codes in front of the store is the other way to stop them (see Bloom filters).
- Hot links. A code going viral is a hot key: tens of thousands of hits a second on one Redis shard. A small in-process cache in each redirect server (a few thousand entries, a few seconds of TTL) absorbs it; Hot keys and cache stampedes covers the options.
- Edge caching. A CDN could answer redirects itself if the response were cacheable by shared caches. That is the strongest latency win and the biggest analytics loss (the clicks it answers never reach you, unless you process the CDN's logs), so this design sends
Cache-Control: private: the browser may reuse the redirect for 90 seconds, shared caches may not. The same trade-off decides the status code.
301 or 302? RFC 9110 (the HTTP semantics standard, 2022) defines 301 Moved Permanently as heuristically cacheable: without explicit cache headers, browsers and proxies may store it and send later clicks straight to the long URL without asking you. 302 Found means "temporarily elsewhere" and isn't cached unless you say so. So:
Without explicit cache headers, the two behave like this:
| 301 (or 308) | 302 (or 307) | |
|---|---|---|
| Later clicks from the same browser | Often never reach you | Reach you every time (or after max-age) |
| Click analytics | Undercounted | Accurate |
| Load on your servers | Lower | Higher |
| Changing or disabling a link | Cached copies keep redirecting | Takes effect at once (or after max-age) |
In practice the explicit Cache-Control header matters more than the status code. Bitly, which sells click analytics, answers 301 with Cache-Control: private, max-age=90, and TinyURL answers 301 with Cache-Control: no-store (both checked with curl -I, October 2026): the header caps how long a browser may reuse the redirect, so the "permanent" status costs them little. This design answers 302 with the same short private max-age: the header does the real work, and 302 means a cache that ignores or loses the header still won't treat the link as permanent. The ability to disable a malicious link quickly matters more than the saved requests. (307/308 are the versions that forbid changing POST to GET; for plain link clicks, which are GETs, they behave the same as 302/301.)
Counting clicks synchronously (UPDATE … SET clicks = clicks + 1 on every redirect) would turn a 35,000-reads-a-second path into 35,000 writes a second on a handful of hot rows. Instead the redirect service publishes an event and moves on, which is the Pub/sub and fan-out pattern:
- Events go to a Kafka topic partitioned by code, so one consumer sees all clicks for a code in order. The catch is the viral link again: every click on it lands on one partition, so one broker and one consumer take tens of thousands of events a second while the rest idle. Click counts don't need per-code order, so the fixes are to partition by a hash of the event id (or randomly) and aggregate per code downstream (each consumer keeps partial counts, and the
clicks_dailyupsert adds them), or to pre-aggregate at the producer: each redirect server counts clicks per code in memory and publishes one "code, +n" event every few seconds. - A stream job (Flink, Kafka Streams, or a plain consumer) keeps per-code counters in memory and flushes
clicks_dailyevery minute: 35,000 events a second (2.1 million a minute) become one upsert per code clicked in that minute, so a viral link's thousands of clicks cost one write, not thousands. (The long tail of links clicked once or twice still costs about one write per click; batch those upserts.) - "Unique visitors" per link is a count-distinct problem; a HyperLogLog per code per day estimates it with a standard error of 0.81% in at most 12 KB (Redis's implementation) (see HyperLogLog and count-min sketch).
- If Kafka is down, the redirect still works; the service buffers events in memory, can spool them to local disk to ride out a longer outage and replay them afterwards, and drops them only when that fills up. Losing some clicks is acceptable by the requirements; failing redirects is not. Say that priority out loud.
- Custom aliases share the
codekey space. The create service does a conditional insert of the alias; a clash returns409. Reserve aliases that look like generated codes (exactly 7 base62 characters) for the generator, or a user could squat a future code. - Expiry is checked on read (
expires_at < now()→410 Gone), and a background job deletes expired rows lazily. The cache entry's TTL should never outlive the link's expiry. - Abuse. Shorteners hide phishing links. Check new URLs against a blocklist or a reputation service at creation time and re-scan popular links in the background; a disabled link answers
410within the cache's TTL, which is another reason to keep TTLs short. - Same URL twice. With counter ranges, two users shortening the same URL get two codes. That's usually what you want (separate analytics per owner). If you want dedup, keep a
hash(long_url, owner) → codetable and look it up first.
Trade-offs and what to say
Trade-offs to name before the interviewer does:
- Counter vs hash: the counter needs a small, highly available allocator; the hash needs a check on every write and grows more collisions over time. Ranges make the allocator cheap.
- 302 vs 301: you pay for every repeat click in exchange for accurate analytics and instant link changes.
- Consistency: a just-created link must redirect at once. Write to the store first, then return; the cache is filled on the first read, so for a generated code there's no stale entry to invalidate. A custom alias is the exception: if someone (or a bot) opened
sho.rt/spring-salebefore it existed, the cache holds a 60-second "not found" entry for it, so creating the alias must also delete that negative entry, or the new link answers404for up to a minute. Cross-region, either route creates to one home region and replicate (a link created in Europe may take a second to work in Asia), or allocate disjoint id ranges per region so each region creates locally. - What breaks first at 10× scale: the hot-key path (viral links) and the cache size, not the database writes.
Your interviewer suggests: "Six characters is shorter. Hash the URL, take 6 base62 characters, and retry on collision. Why not?"
Show answer
Six characters can't hold five years of links at all, and the retry loop gets steadily worse long before you run out.
- Six base62 characters give
62^6= 56.8 billion codes; the design needs 182.5 billion. The estimates block prints "6 chars: 56.8B codes, too few": the table would be full in about 1.6 years. - Collision probability per insert equals the fraction of codes already taken. After one year (36.5 billion links) that's 36.5 / 56.8 = 64%: most inserts would need at least one retry, and many several.
- Every retry is another read against the store on the write path, so write latency and store load climb month by month.
- With 7 characters the same year-one table is 1% full (36.5 billion of 3.5 trillion), and with counter ranges there are no collisions at all, so the "shorter" code costs far more than the one character it saves.