Walkthrough: URL Shortener
The full framework applied end-to-end to Bitly/TinyURL — key generation, read-heavy caching, and redirects — as the reference example for every later question.
The question
"Design a URL shortener like Bitly: users submit a long URL and get back a short one; anyone who visits the short URL is redirected to the original."
This is the classic warm-up, often used verbatim at mid-level — which makes it the perfect place to watch the whole 8-step framework run end to end. Everything below is written the way you would actually say it.
Step 1: Requirements (~5 min)
Functional — proposed and confirmed with the interviewer:
- Users can submit a long URL and receive a short URL (optionally choosing a custom alias).
- Visiting a short URL redirects to the original URL.
- Links can have an optional expiration time.
Out of scope, confirmed: click analytics dashboards, user accounts management.
Non-functional — quantified:
- Redirects must be fast: under 100ms, since a redirect sits in front of every page load it serves.
- Availability over consistency for redirects — if a just-created link takes a few seconds to work everywhere, that is acceptable; the redirect service being down is not.
- Short codes must be unique — two long URLs must never collide on one code.
- Scale assumption: on the order of 1B URLs total and 100M DAU doing redirects. Read:write ratio is extreme — roughly 1000:1 — because a link is created once and clicked thousands of times. I announce my estimation policy: "I'll run numbers when they change a decision."
Step 2: Capacity — inline, when it decides something
Two calculations matter here, so I do them now.
Storage: 1B URLs × ~500 bytes per row (long URL, short code, timestamps) ≈ 500GB. A single Postgres handles a few TB comfortably — so no sharding. Saying this out loud is the point: I did the math and it produced a decision.
Traffic: 100M DAU × ~1 redirect ÷ ~100,000 seconds/day ≈ 1,000-1,200 QPS average, call it ~5,000 QPS peak (peak runs 2-5x average). That is a cache-friendly read load, not exotic scale. Writes: even 10M new URLs/day is ~115/sec — trivial.
Conclusion, stated as decisions: one Postgres with replicas for reads, a cache for hot redirects, no sharding, no exotic datastore.
Step 3: Core entities (~2 min)
- User — the creator of a link.
- ShortUrl — the mapping: short code, original URL, creator, created_at, optional expires_at.
Two entities. I say: "I'll add columns as the design firms up."
Step 4: API (~5 min)
One endpoint per functional requirement:
POST /v1/urls
body: { "longUrl": "https://...", "customAlias": "launch"?, "expiresAt": "..."? }
-> 201 { "shortUrl": "https://short.ly/abc123" }
GET /{shortCode}
-> 302 Location: https://original-long-url.com/...
The creator comes from the auth token, not the body. And one decision worth narrating now: the redirect returns 302 (temporary redirect), not 301 (permanent). A 301 tells browsers to cache the mapping and skip our server on future visits — faster for the user, but we lose the ability to expire or update links and we never see the click (goodbye analytics, if they are ever in scope). 302 keeps control at the server. Interviewers love this question; have the answer ready.
Step 5: Data flow — skipped
"This is a CRUD system, not a pipeline, so I'll skip the data-flow step and go straight to the design." One sentence; skipping explicitly shows you know the step exists.
Step 6: High-level design (10-15 min)
Walk each endpoint through the system.
POST /v1/urls: client → API gateway (auth, rate limiting — a shortener is an abuse magnet) → Write service → generate a short code (deep dive coming; I flag it: "code generation has a uniqueness problem across servers — I'll come back to it") → insert row into Postgres. Schema jotted next to the DB: short_code (PK), long_url, creator_id, created_at, expires_at.
GET /{shortCode}: client → gateway → Read service → check Redis cache for the code; on a hit (~1ms), return 302 immediately. On a miss, query Postgres — with an index on short_code, that lookup is a few milliseconds, not a table scan — cache the result, return the 302. Expired links (expires_at in the past) return 404.
At this point the system satisfies all three functional requirements: create, redirect, expire. Baseline complete — now the non-functional ones.
Step 7: Deep dives (~10 min)
Deep dive 1: unique short-code generation. The problem, precisely: multiple write servers must generate short, unique codes without stepping on each other.
- Option A — hash the long URL (e.g. take 6-8 chars of an MD5): no coordination needed, but different URLs can collide on the same prefix, so every insert needs a collision check and retry — and two users shortening the same URL get the same code, which breaks per-user expiration.
- Option B — a global counter, encoded in base62: each new URL takes the next integer, encoded into
[0-9a-zA-Z]. Collision-free by construction, and compact: 62^6 ≈ 56 billion codes — six characters cover our 1B URLs 50 times over.
Trade-off: the counter needs central coordination; the hash does not but buys collision handling forever. I choose the counter, and fix the coordination cost with batching: a central Redis counter hands each write server a block of 1,000 IDs at a time (one Redis call per thousand URLs), and servers allocate from their block in memory. If a server dies, its unused block is wasted — at 56 billion codes, who cares. Follow-up I should pre-empt: "what if Redis loses the counter?" — Redis persistence (AOF), and on recovery, restart the counter safely above SELECT max(id) from Postgres, skipping a block to be safe.
Deep dive 2: the <100ms redirect at 5k QPS peak. The problem: every redirect hitting Postgres (20-50ms per query) burns most of the latency budget and hammers the DB with 5k QPS of repetitive reads.
- Option A — read replicas only: spreads load, but every read still pays the DB query cost.
- Option B — cache-aside Redis in front: the read service checks the cache first, fills it on a miss. With a 1000:1 read:write ratio and popular links dominating traffic, the hit rate will be very high; a Redis hit is ~1ms, and one Redis instance handles 100k+ ops/sec — 5k QPS is nothing.
I choose cache-aside with a TTL of a few hours. Invalidation is easy here because mappings are essentially immutable — the one mutation is expiration/deletion, so the write path deletes the cache key when a link is expired or removed. For a global audience I would also mention edge caching: redirects can be served from CDN edge locations to beat geography.
Deep dive 3 (if steered there): scaling the read path further. Read replicas behind the cache for misses; the write path is so light it never needs more than the one primary. This is a place to explicitly not over-engineer: "I would not shard — 500GB fits one instance, and the math from earlier is why."
Step 8: Wrap-up (2-3 min)
"To recap against the requirements: creation works via the write service with collision-free base62 codes from a batched counter; redirects meet the 100ms target via cache-aside Redis with an indexed DB fallback; expiration is a column check plus cache delete. We prioritized availability, and the math showed one Postgres plus replicas is enough — no sharding. With more time I'd look at abuse prevention (rate limiting malicious link creation) and click analytics. Anything you'd like me to go deeper on?"
Complete system, every requirement checked, ended on purpose. That is the template — every question you practice from here reuses this exact shape.