Scope the requirements
This looks trivial, which is exactly the trap. The interviewer wants to see you find the two real problems fast: unique short-code generation at scale, and a redirect path that is almost all reads.
Functional — what it must do
- Given a long URL, return a unique short link (~7 characters).
- Visiting the short link redirects to the original URL.
- Links support an optional expiry (default: never).
- Basic click analytics per link (count is enough for v1).
- Explicitly cut: user accounts, custom aliases, dashboards — offer them back as extensions.
Non-functional — the numbers that shape the design
- Read-heavy: assume 100 redirects for every new link created.
- Redirect latency under 100 ms — this sits in the critical path of someone else's page load.
- 99.99% availability on the redirect path; creation can tolerate brief outages.
- Short codes must never collide; once issued, a link must keep working for years.
- Scale target: 100M new links per month, multi-year retention.
The write path is boring — the whole question is the read path and how I mint codes. I'll design for a 100-to-1 read-to-write ratio and treat the redirect as the availability-critical surface.
Back-of-envelope estimates
Two minutes of arithmetic, all of it feeding later decisions: the QPS says one database can cope but a cache is what serves reads; the storage math says this fits comfortably without sharding for years.
| Quantity | Estimate | How you got there |
|---|---|---|
| Writes | ~40/s | 100M links/month ÷ ~2.6M s per month; call it 40, peak ~100. |
| Redirects (reads) | ~4,000/s | 100:1 read ratio on writes; peak ~10k/s. |
| Storage per link | ~500 B | long URL (~200 B) + code + timestamps + counters, with slack. |
| Storage per year | ~600 GB | 1.2B links × 500 B. Ten years is 6 TB — one Postgres box with replicas, no sharding needed. |
| Cache for hot links | ~25 GB | 80/20 rule: cache ~20% of a year's links; even less is fine — hotness is extremely skewed. |
| Code space | 62⁷ ≈ 3.5 trillion | 7 base62 chars outlast us by centuries at 1.2B/year. |
4k reads a second with a 100 ms budget means the redirect can't routinely hit the database — Redis serves it in under a millisecond and Postgres becomes the source of truth, not the hot path.
Sketch the API
Two endpoints do all the work. The interesting choice is the redirect status code, which most candidates get wrong by reflex.
- 302 over 301, deliberately: a 301 is cached permanently by browsers, so repeat visits never reach you again — you lose analytics and the ability to update or expire the link. 302 keeps every click observable. If the interviewer pushes on redirect latency, offer 301 as the optimisation and name what it costs.
- Creation is idempotent per (user, longUrl) if you dedupe — but say that global dedupe is a choice, not a requirement: two users shortening the same URL can get different codes.
Data model
One core table, one counter table. The restraint is the point — reach for exactly as much database as the data needs.
I'd start with Postgres. Six terabytes over ten years fits a single primary with read replicas, and I get transactions for the counter logic for free. If the interviewer scales writes 100×, then I move to a partitioned store like DynamoDB with code as the partition key — and I'd say that migration out loud rather than starting there.
High-level design
Separate the two paths explicitly: a boring write path into Postgres, and a read path that lives in Redis with the database as fallback. Analytics leaves the hot path through a queue.
- Create: client POSTs a long URL → the create service takes the next ID from its pre-leased counter range, encodes it base62, inserts the row, returns the short link.
- Redirect: browser hits
GET /{code}→ redirect service checks Redis (~1 ms, >90% of traffic ends here) → on miss, reads Postgres and back-fills the cache with TTL. - The redirect service drops a click event onto Kafka after sending the 302 — analytics can lag; the user never waits for it.
- An aggregator consumes events and rolls them into
click_countshourly. Stats reads never touch the event log.
Deep dives — where the interview is won
Minting short codes: counter + base62 beats hashing
There are three standard options. Hash the URL (MD5, take 7 chars): collisions are guaranteed at scale by the birthday paradox, so every insert needs a check-and-retry loop, and two users shortening the same URL get the same code, which breaks per-user analytics and deletion. Random codes: no coordination but the same collision-check tax on every write, growing as the space fills. A global counter encoded in base62: zero collisions by construction, codes are short, generation is a single atomic increment.
The counter's weakness is that it's a single point of coordination. The standard fix: each application server leases a range — say a million IDs — from ZooKeeper or a database row with an atomic UPDATE ... RETURNING, then mints from that range in memory with no coordination at all. A crashed server strands at most one range, and 62⁷ means wasted ranges cost nothing.
The read path: cache-aside with a negative-lookup guard
Redis maps code → long_url with cache-aside: redirect checks Redis, misses go to Postgres and back-fill with a TTL of a day or so. Hot links live in memory permanently in practice because every hit refreshes them; 25 GB of cache covers far more than the traffic ever asks for.
The subtle attack: requests for codes that don't exist always miss the cache and always hit the database — an attacker spraying random codes turns your cache into a bypass. Two standard guards: cache negative results (code → NOT_FOUND, short TTL), or keep a Bloom filter of all issued codes in front of the database — a few GB of bits answers "definitely not a link" without any I/O.
Expiry without a delete storm
Millions of links expiring at midnight must not become a mass-delete job that locks the table. The standard answer is lazy expiry plus a slow janitor: the redirect path checks expires_at when it reads a row and returns 404 for dead links immediately — correctness comes from the read path, not from deletion. A background job then trickles through and hard-deletes expired rows in small batches during quiet hours, purging cache entries as it goes.
This is the same pattern Redis itself uses for TTLs, which is worth saying — it signals you know the idea generalises.
expires_at < now. janitor deletes in small batches.Scaling the last piece: what breaks first at 10×
If the interviewer multiplies traffic by ten, walk the pieces in failure order. Reads: Redis scales with replicas and consistent-hashed shards — no real problem. The redirect tier is stateless — autoscale. Writes at 400/s are still trivially fine for Postgres. The first genuine wall is storage growth and analytics write volume, so the moves are: partition links by hash of code (or migrate to DynamoDB with code as partition key), and keep click events in Kafka with aggregates in something column-friendly.
The point to land: the design doesn't change shape at 10× — capacities change. A candidate who redesigns from scratch at every scale multiplier signals they didn't understand the first design.
Follow-ups you should expect
Why 302 and not 301? Wouldn't 301 be faster?
How do you handle two requests shortening the same URL at once?
What happens if Redis goes down entirely?
How would you support custom aliases?
Could you do this without any coordination service for the counter?
UPDATE counter SET next = next + 1000000 RETURNING next, which is atomic and needs no ZooKeeper. Or mint Snowflake-style IDs (timestamp + machine ID + sequence) locally, accepting slightly longer codes. Both remove the external dependency; the DB-lease version is what I'd ship.Where candidates lose the room
- Jumping straight to "hash the URL" and only discovering collisions when prompted — the ID-generation trade-off is the core of this question.
- Answering 301 by reflex because "it's permanent" — and thereby silently deleting the analytics requirement agreed five minutes earlier.
- Sharding Postgres on day one for 600 GB/year — over-engineering the easy dimension while ignoring the cache stampede on the hard one.
- Putting the click-count UPDATE on the redirect path, adding a write to every read and serialising hot links on a row lock.
- Forgetting that non-existent codes bypass the cache — the one denial-of-service vector this design has.