Design a URL shortener (TinyURL / bit.ly)
The interviewer asks
“Design a service like bit.ly. Users paste a long URL and get a short link such as sho.rt/aZ3kQ9x. Anyone who opens the short link is redirected to the original URL. Assume around 100 million new links a month.”
What a strong answer looks like
A strong answer notices early that this is a read-heavy system (about 100 reads per write) where the redirect path must be very fast and almost never fail. Generate short codes without collisions using a counter-based ID encoded in base62 (or pre-generated key ranges), not by hashing and retrying. Store code -> URL in a key-value store partitioned by code, and put a cache and the CDN edge in front so most redirects never touch the database. Choose 301 vs 302 on purpose, because it decides whether you can count clicks. Record clicks asynchronously through a queue into an analytics store, so analytics can never slow down or break redirects. Finish with expiry, custom aliases, abuse checks and what happens when the cache or the ID service fails.
Concepts to mention
1 Requirements
Functional
- Create a short link for a long URL; return the same style of short code every time (7 characters, base62)
- Redirect: GET /{code} returns an HTTP redirect to the original URL
- Optional custom alias (sho.rt/diwali-sale) if it is free
- Optional expiry time per link; expired links return 404/410
- Basic click analytics per link: total clicks, clicks per day, top countries and referrers
- Owners can disable a link (abuse, phishing)
Non-functional
- Redirect latency p99 under 50 ms at the server (excluding the user's network)
- Redirect availability 99.99%; creating links can be 99.9%
- Scale: 100M new links/month, about 10B redirects/month, peaks of 5x average during campaigns
- Short codes must never collide and should not be easy to enumerate
- Links live for 5 years by default; storage must hold 6B+ links
- Analytics can lag by up to a few minutes
Out of scope (say it out loud)
- User accounts, billing and team management (assume an auth service exists)
- QR code generation and branded domains (mention only)
- Full marketing analytics dashboards
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/links | Body: {long_url, custom_alias?, expires_at?}. Returns {code, short_url}. Requires API key; idempotency key header avoids duplicate links on retry. |
GET/{code} | Public redirect. Returns 302 (or 301) with Location header; 404 if unknown, 410 if expired or disabled. |
GET/v1/links/{code} | Owner reads link metadata (long URL, created, expiry, status). |
GET/v1/links/{code}/stats?from=&to= | Click counts by day, country and referrer from the analytics store. |
DELETE/v1/links/{code} | Owner disables a link; also invalidates cache and CDN. |
4 Data model
linksDynamoDB / Cassandracode (PK)long_urlowner_idcreated_atexpires_atstatus (active|disabled)is_customPure key lookup by code, 6B rows, so a partitioned key-value store fits better than one Postgres. Partition key = code spreads load evenly. TTL attribute handles expiry cleanup. A secondary index on owner_id serves 'my links' pages.
id_rangesPostgres / ZooKeeper / etcdrange_startrange_endallocated_toallocated_atTiny, strongly consistent table. Each app server grabs a block of 1M IDs at a time, so this is hit rarely.
link_cacheRediskey = codevalue = long_url|status|expires_atTTL 24 hRead-through cache in front of the KV store. Also cache negative results (unknown codes) for a short TTL to stop scans hammering the DB.
click_eventsKafka → ClickHousecodetscountryreferrerdeviceip_hashAppend-only events. ClickHouse keeps raw events for 90 days and daily rollups per code for 5 years.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Create a short link
- 1Client sends POST /v1/links with long_url and an Idempotency-Key header.
- 2Link API validates the URL (scheme http/https, length under 2 KB) and checks it against a Safe Browsing / internal blocklist.
- 3If a custom alias is given, it does a conditional write (put if not exists) with code = alias; on conflict it returns 409.
- 4Otherwise it takes the next number from its locally leased ID block (e.g. 1,000,000-1,999,999), no network call needed.
- 5It scrambles the number with a reversible permutation and encodes it in base62 to 7 characters, e.g. 'aZ3kQ9x'.
- 6It writes {code, long_url, owner, expires_at} to DynamoDB with a condition that the code does not exist.
- 7It returns 201 with short_url; it stores the idempotency key -> code for 24 h so a retry returns the same link.
Redirect a short link
- 1Browser requests sho.rt/aZ3kQ9x; the CDN serves a cached redirect if it has one (only for links where we allow edge caching).
- 2On a miss, the load balancer routes to a stateless redirect service instance.
- 3Redirect service reads code from Redis (~1 ms). On a miss it reads DynamoDB (~5-10 ms) and fills Redis with a 24 h TTL.
- 4It checks status and expiry; disabled or expired links return 410, unknown codes 404 (negative-cached for 5 minutes).
- 5It returns 302 with Location: long_url and Cache-Control set by link type.
- 6Without waiting, it publishes a click event (code, ts, country from GeoIP, referrer, device) to Kafka via an in-memory buffer.
- 7Analytics consumers batch events into ClickHouse every few seconds; stats pages read rollups from there.
7 Deep dives
Generating short codes without collisions
There are three common options. (1) Hash the long URL (MD5/SHA-256) and take 7 base62 characters. It is simple, but different URLs can collide, so you need a check-and-retry loop on every write, and the same URL always maps to the same code, which breaks per-user analytics. (2) Random 7-character codes with a conditional insert. With 3.5 trillion possible codes and 6B used, the collision chance per insert is about 0.2%, so retries are rare and cheap; this is a fine answer. (3) A counter: a unique 64-bit number encoded in base62. It never collides and needs no retry. The weak point of a single counter is that it is a bottleneck and single point of failure, so each Link API server leases a block of 1M IDs from a small coordinator (Postgres row with SELECT ... FOR UPDATE, or etcd). At 40 writes/s a block lasts hours. If a server crashes, the unused part of its block is simply lost, which is fine with 3.5 trillion codes. Plain counters create guessable codes (aaaaaab, aaaaaac), which lets people scrape private links. Fix it by passing the number through a reversible scramble (for example a Feistel network or multiply by a large odd number modulo 62^7) before encoding. You keep uniqueness and lose predictability.
301 vs 302 and caching the redirect
A 301 (Moved Permanently) tells browsers and proxies they may cache the redirect forever. Repeat visitors then never reach your servers: great for load, bad for analytics and for disabling a phishing link, because a cached 301 cannot be taken back. A 302 (or 307) is temporary: every click reaches you (or your CDN), so you can count it and you can change or kill the link at any time. bit.ly-style services use 301 for SEO reasons in some cases but most link products that sell analytics use 302. A good answer: default to 302 with a short Cache-Control (for example max-age=0 for browsers), and let the CDN cache redirects for 60 seconds using surrogate keys so you can purge one code instantly when it is disabled. Clicks served by the CDN are counted from CDN logs (Cloudflare/Akamai log push to Kafka), so analytics still work. This turns a 20k/s peak into a few thousand origin requests per second, and keeps the 'disable link' feature honest within about a minute.
Analytics without slowing redirects
The redirect must never wait for analytics. The redirect service puts the click into an in-memory buffer and a background thread sends batches to Kafka every 50-100 ms (acks=1 is acceptable here; losing a tiny fraction of clicks during a crash is a business decision you should state). If Kafka is down, the buffer is bounded and drops events rather than blocking redirects, and a counter metric tracks drops. Consumers enrich events (GeoIP country, bot detection by user agent and rate) and insert into ClickHouse in batches of 10k-100k rows, which is what ClickHouse is good at. A materialized view keeps daily rollups per code, so a stats query for one link reads a few hundred rows, not millions. Store an IP hash, not the raw IP, to reduce personal data under India's DPDP Act and GDPR. Exactly-once counting is not needed: use the event ID for de-duplication in the rollup if the product sells billing-grade click counts, otherwise at-least-once is fine.
8 Trade-offs
Key-value store vs relational database
The core query is 'get long_url by code', a single-key lookup over 6B rows. DynamoDB or Cassandra gives this with partitioning built in and predictable 5-10 ms reads at any size. Postgres can work too: 3 TB fits in a few shards, and it is a fine answer for a startup at 10M links. The KV store wins at this scale because you never have to plan a resharding. Keep the owner's 'my links' list as a secondary index or a separate Postgres table if you need rich filtering.
Counter-based IDs vs random codes
Counter blocks guarantee no collisions and need no read-before-write, but need a coordinator and a scramble step to avoid enumeration. Random codes need no coordinator at all and are naturally unguessable, but need a conditional write and a rare retry. Both are good answers; say which you pick and why. Pure hashing of the URL is the weakest because collisions grow with data and it cannot give two users separate links for the same URL.
Same URL shortened twice: dedupe or not
Returning the same code for the same long URL saves a little storage but needs an extra index on long_url (large, hot) and mixes analytics from different users. Most products create a new code per request and only dedupe per owner if asked. Storage is cheap; clarity of analytics is worth more.
9 Bottlenecks
Viral link hot key
A link shared during an IPL final can get 50k clicks/s. All of them hit one Redis key and one DynamoDB partition. Fixes: let the CDN cache the redirect for 30-60 s (absorbs almost all of it), keep a small in-process LRU cache in each redirect server (1-2 s TTL), and read Redis replicas. The DB is only hit on cache misses, so it stays safe.
Analytics write volume at 10x
At 200k clicks/s, writing each event to a database row would melt it. Kafka with 64+ partitions keyed by code absorbs it, and ClickHouse batch inserts handle millions of rows/s. If cost matters, pre-aggregate per code per minute in the consumer (Flink or simple in-memory windows) and store only counts plus a sampled raw stream.
10 Failure handling
Redis cluster goes down
Redirects fall back to DynamoDB. At 20k/s peak that is within DynamoDB on-demand limits, but latency rises from ~2 ms to ~10 ms. Protect the DB with request coalescing (one DB read per code at a time per server) and the in-process LRU. Redirects keep working; this is why the cache is an optimisation, not the source of truth.
ID allocator unavailable
Each Link API server holds a leased block of 1M IDs, which at 40 writes/s across 10 servers lasts many hours. Creation keeps working until blocks run out, giving time to fail over the coordinator (Postgres replica promotion or etcd leader election). Redirects are not affected at all.
Kafka unavailable
Click events buffer in memory up to a limit, then are dropped and counted. Redirects never fail because of analytics. Alert on the drop metric; analytics shows a small gap for the outage window.
11 What impresses
- Does the 62^7 math and explains why 7 characters is enough with a margin
- Explains that sequential codes are enumerable and fixes it with a reversible scramble
- Chooses 302 deliberately and explains how 301 would break analytics and link disabling
- Keeps analytics fully off the redirect path, with a bounded buffer that drops instead of blocking
- Handles a viral hot key with CDN plus in-process cache, not just 'add Redis'
- Mentions phishing/malware checks and the ability to kill a link within a minute
12 Mistakes
- Using a hash of the URL without handling collisions
- A single global auto-increment counter with no plan for its failure or throughput
- Writing analytics synchronously to the database in the redirect request
- Choosing 301 without noticing it hides clicks and cannot be undone
- Spending most of the time on the create path, which is only 40 QPS, and ignoring the read path
- No numbers: not estimating storage or why a cache is needed
13 Follow-ups
Lessons this design uses
Practice questions
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.