Blueprint
Fundamentals foundation

Design a URL shortener

The classic warm-up that still fails people: a tiny write path, a brutal read path, and one ID-generation decision the whole interview hangs on.

asked at Amazon · Microsoft · Adobe Frequently asked ~30 min walkthrough
01

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.
Say this out loud

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.

02

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 actually serves reads; the storage math says this fits comfortably without sharding for years.

QuantityEstimateHow you got there
Writes~40/s100M links/month ÷ ~2.6M s per month; call it 40, peak ~100.
Redirects (reads)~4,000/s100:1 read ratio on writes; peak ~10k/s.
Storage per link~500 Blong URL (~200 B) + code + timestamps + counters, with slack.
Storage per year~600 GB1.2B links × 500 B. Ten years is 6 TB — one Postgres box with replicas, no sharding needed.
Cache for hot links~25 GB80/20 rule: cache ~20% of a year's links; even less is fine — hotness is extremely skewed.
Code space62⁷ ≈ 3.5 trillion7 base62 chars outlast us by centuries at 1.2B/year.
Say this out loud

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.

03

Sketch the API

Two endpoints do all the work. The interesting choice is the redirect status code, which most candidates get wrong by reflex.

POST/api/v1/linksbody: { longUrl, expiry? } → { shortUrl }. Authenticated, rate-limited per API key.
GET/{code}302 redirect to the long URL. The hot path — no auth, no body, one cache lookup.
GET/api/v1/links/{code}/stats→ { clicks, createdAt, expiry }. Reads from the analytics store, not the redirect path.
DELETE/api/v1/links/{code}Soft-delete; the code is never reissued.
  • 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.
04

Data model

One core table, one counter table. The restraint is the point — reach for exactly as much database as the data needs.

links
code PK · long_url · created_at · expires_at · user_id?
Primary lookup is by code — index comes free with the PK.
click_events
code · ts · country? (append-only)
Written async by a consumer, aggregated hourly into click_counts. Never touched on the redirect path.
click_counts
code PK · total · updated_at
Serves the stats endpoint in one read.
The database call

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.

05

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.

client edge service data async Browser API client Load balancer Redirect service stateless, autoscaled Create service code minting Redis code → URL Postgres links, counters Counter ranges ZooKeeper / DB Click queue Kafka Aggregator lookup range lease hourly rollup insert GET /{code} POST /links on miss click event
solid lines are the synchronous request path; dashed coloured lines are fire-and-forget. notice the redirect service never waits on anything but redis.
  1. 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.
  2. 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.
  3. The redirect service drops a click event onto Kafka after sending the 302 — analytics can lag; the user never waits for it.
  4. An aggregator consumes events and rolls them into click_counts hourly. Stats reads never touch the event log.
06

Deep dives — where the interview is won

Phase 01

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.

app counter App node 1 App node 2 App node 3 Counter row atomic UPDATE lease 1M lease 1M lease 1M
counter shards lease million-ID ranges. app servers mint in RAM.
Trade-offSequential codes are enumerable — competitors can crawl /aaa001, /aaa002 and scrape your links. If that matters, apply a fixed permutation (bijective scramble) to the counter before encoding. Say this unprompted; it's the follow-up interviewers hold in reserve.
Phase 02

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.

request gate source GET /{code} Bloom filter issued codes Neg cache not-found TTL Redis Postgres yes miss in set? known 404
bloom filter answers 'definitely not a link' without disk. negative cache absorbs junk misses.
Trade-offA Bloom filter gives false positives (a tiny fraction of junk requests still reach the DB, which is fine) but never false negatives — it will never reject a real link. Rebuilding it on deploys is the operational cost you accept.
Phase 03

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.

read janitor db Redirect Janitor batches, off-peak links small DELETE check ts
read path returns 404 on expires_at < now. janitor deletes in small batches.
Trade-offExpired rows linger on disk until the janitor reaches them; you trade storage slack for never having a delete-storm latency spike.
Phase 04

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.

load tier 10× traffic Redis replicas Autoscale svc Partitioned links reads
at 10× traffic the shape stays; capacities move. one migration: partition links table.
Trade-offMoving links to a partitioned NoSQL store gives you effortless write scale but costs you the transactional counter rollups and ad-hoc SQL — you'd rebuild analytics on the event stream instead.
07

Follow-ups you should expect

Why 302 and not 301? Wouldn't 301 be faster?
301 is cached permanently by the browser, so repeat visits skip your servers entirely — faster for the user, but you lose click analytics, and you can never expire or fix the destination. 302 keeps every click visible at the cost of one round trip. Analytics is a stated requirement, so 302. If analytics were dropped, I'd flip to 301 and let browsers do my caching for free.
How do you handle two requests shortening the same URL at once?
With counter-based codes they simply get two different codes — no conflict exists by construction. If the product wants global dedupe, add a unique index on a hash of the long URL and handle the constraint violation by returning the existing code. That's a product decision, not a correctness problem.
What happens if Redis goes down entirely?
Redirects fail over to Postgres reads — latency degrades from ~1 ms to maybe 10 ms, and the database sees 4k QPS of point lookups on a primary-key index, which a replica pool absorbs. It's a brown-out, not an outage. I'd also rate-limit backfill on recovery so a cold cache doesn't stampede the database.
How would you support custom aliases?
A second namespace: custom aliases go in the same table but skip the counter — insert with a unique constraint, reject on conflict. Reserve a prefix or length range so generated codes can never collide with them. The subtle cost is that popular words become contested resources, so add per-user quotas.
Could you do this without any coordination service for the counter?
Yes — lease ranges from a Postgres row via 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 actually has.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.