URL shortener: ids, collisions, redirects, caching, analytics, abuse
coreintermediateA URL shortener looks trivial and is the standard interview problem because every interesting decision in it is forced by one number: reads outnumber writes by orders of magnitude, so the redirect path is the design and everything else is secondary. The short code can be generated three ways — a counter encoded in base62, which is compact and sequential but leaks how many links exist and lets anyone enumerate them; a random string, which needs a uniqueness check on insert; or a hash of the URL, which gives the same code for the same link and needs collision handling anyway. Collisions are handled by making the code column unique and retrying on violation, not by checking first and then inserting, because the check-then-insert has a race between the two statements. The redirect itself is a choice with consequences: a 301 is cached by browsers and intermediaries so subsequent clicks never reach you, which is fast and destroys your click analytics, while a 302 keeps every click coming to your server, which is slower per click and is what you want if the link can expire or be re-pointed. Reads are served from a cache keyed by short code, which works unusually well here because the mapping is immutable for the life of the link. Analytics are written asynchronously — never in the redirect path, where a slow write would add latency to the one operation that has to be fast. Expiration is a stored timestamp checked at redirect time rather than a delete job, so an expired link answers correctly the moment it expires. Abuse prevention matters because a shortener is an open redirect by definition: rate-limit creation, check destinations against a malicious-URL list, and support takedown. And scaling is easy in the good direction — the redirect path is stateless and cacheable, so it scales horizontally, while writes are low enough volume to stay on one primary for a long time.
Think of it as
A cloakroom ticket. Handing in a coat is rare and can be slow; showing the ticket happens constantly and must be instant. So you design the ticket lookup — a small immutable map from ticket number to peg — and let everything else be leisurely. Every awkward choice in a shortener comes from remembering which of those two operations you are on: creation can afford a uniqueness retry, a malware scan and a database write, and redirection can afford none of them.
What we're doing: Size the system from one assumption, then read the design off the numbers.
- 6
- The 100:1 ratio is the single number that decides the architecture. It is what makes a cache the primary scaling mechanism and makes the write path's cost almost irrelevant.
- 14
- Twelve writes per second is the load an ordinary laptop handles. Recognising this stops a design from reaching for write sharding, distributed id generation or a queue in front of creation — all of which would be real complexity bought against a load that does not exist.
- 24
- Sizing the code space is what turns "how long should the code be" from taste into arithmetic. Seven base62 characters carry 3.5 trillion codes, so the length is set by the collision and enumeration story rather than by capacity.
Why this works: Every design decision in a shortener falls out of the read-to-write ratio and the code-space arithmetic, so doing those two calculations first replaces most of the argument. The numbers also say what not to build: at twelve writes a second, distributed id generation and write sharding are answers to a problem this system does not have.
Checking for a free code, then inserting it
Wrong
Better
What you see: Two links occasionally share a code under concurrent creation, so one of them silently redirects to the other's destination. It reproduces only under load and looks like data corruption rather than a race.
Why: Between the existence check and the insert, another writer can take the same code, so the check describes a state that is already stale when the insert runs. A unique constraint plus an atomic insert-if-absent moves the decision into the database, where the two operations cannot interleave.
- GET /aX9k2
- leads to Cache lookup by code
- Cache lookup by code — immutable value — high hit rate
- leads to Database read on miss (miss)
- leads to Expired? blocked? (hit)
- Database read on miss — primary key lookup
- leads to Expired? blocked?
- Expired? blocked? — compared on already-loaded data
- leads to 302 to target URL
- on error, leads to 410 Gone
- 302 to target URL
- leads to Enqueue click event
- 410 Gone — expired or taken down
- Enqueue click event — asynchronous, never blocking
Three ways to generate the short code
301 versus 302 for the redirect
Each requirement and where it lives in the design
Remember: Reads outnumber writes roughly 100:1, so the redirect path is the design. Generate codes with a base62 counter, a random string or a URL hash, and handle collisions with a unique constraint plus retry rather than check-then-insert. Choose 302 whenever analytics or expiry matter, because a 301 is cached by clients and never comes back. Cache the immutable code-to-URL mapping, write analytics asynchronously, check `expires_at` at redirect time, rate-limit and screen at creation, and scale the stateless redirect tier horizontally.
See also: cache patterns · id schemes and their tradeoffs · what to estimate · rate limit scope · cache control and invalidation

