Design problems
Classic building blocksEasySDE-1 25 min

Design a URL shortener (TinyURL / bit.ly)

Asked at
AmazonFlipkartGoogleMicrosoftPaytmRazorpay

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

read-heavy 100:1 ratiobase62 encoding and code length mathcounter blocks / range allocation or random code with conditional writenon-guessable codeskey-value store partitioned by codecache-aside with Redis and negative caching301 vs 302 trade-offasync click analytics via KafkaCDN caching of redirects with purgeexpiry with TTLabuse and phishing checks

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

Write QPS~40/s (peak ~200/s)100M links/month ÷ (30 × 86,400 s) ≈ 39/s; ×5 for campaign peaks
Read (redirect) QPS~4,000/s (peak ~20k/s)100:1 read:write → 10B redirects/month ÷ 2.6M s ≈ 3,860/s; ×5 peak
Links stored over 5 years~6B100M/month × 12 × 5 = 6B rows
Storage for links~3 TB (plus replicas)~500 bytes/row (URL avg 200 B, code, owner, timestamps, metadata) × 6B ≈ 3 TB; ×3 replication ≈ 9 TB
Code length7 base62 characters62^7 ≈ 3.5 trillion codes, about 580× the 6B we need; 6 chars (56.8B) would also fit but leaves little room
Cache size~20-50 GBTraffic follows a power law: top 20% of links get ~80% of clicks. 20% of a day's active links × 500 B fits in a small Redis cluster
Click events~10B/month, ~1 TB/month raw10B events × ~100 B each (code, ts, country, referrer, device) ≈ 1 TB; store aggregates long term

3 API

EndpointWhat it does
POST/v1/linksBody: {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 / Cassandra
code (PK)long_urlowner_idcreated_atexpires_atstatus (active|disabled)is_custom

Pure 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 / etcd
range_startrange_endallocated_toallocated_at

Tiny, strongly consistent table. Each app server grabs a block of 1M IDs at a time, so this is hit rarely.

link_cacheRedis
key = codevalue = long_url|status|expires_atTTL 24 h

Read-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 → ClickHouse
codetscountryreferrerdeviceip_hash

Append-only events. ClickHouse keeps raw events for 90 days and daily rollups per code for 5 years.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALGET /{code}cache missPOST /v1/linksget codeon cache missclick eventlease 1M IDsput if absentURL reputationbatch insertBrowser / appLoad balancerCDN edge (cached301/302)Redirect serviceLink API(create/manage)ID range allocatorKafka: click eventsAnalytics consumerRedis link cacheLinks KV (DynamoDB)ClickHouse analyticsSafe Browsing check
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Create a short link

  1. 1Client sends POST /v1/links with long_url and an Idempotency-Key header.
  2. 2Link API validates the URL (scheme http/https, length under 2 KB) and checks it against a Safe Browsing / internal blocklist.
  3. 3If a custom alias is given, it does a conditional write (put if not exists) with code = alias; on conflict it returns 409.
  4. 4Otherwise it takes the next number from its locally leased ID block (e.g. 1,000,000-1,999,999), no network call needed.
  5. 5It scrambles the number with a reversible permutation and encodes it in base62 to 7 characters, e.g. 'aZ3kQ9x'.
  6. 6It writes {code, long_url, owner, expires_at} to DynamoDB with a condition that the code does not exist.
  7. 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

  1. 1Browser requests sho.rt/aZ3kQ9x; the CDN serves a cached redirect if it has one (only for links where we allow edge caching).
  2. 2On a miss, the load balancer routes to a stateless redirect service instance.
  3. 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.
  4. 4It checks status and expiry; disabled or expired links return 410, unknown codes 404 (negative-cached for 5 minutes).
  5. 5It returns 302 with Location: long_url and Cache-Control set by link type.
  6. 6Without waiting, it publishes a click event (code, ts, country from GeoIP, referrer, device) to Kafka via an in-memory buffer.
  7. 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

Q1How would you support custom aliases without clashing with generated codes?
Q2How would you stop people from scanning all codes to find private links?
Q3A link turns out to be phishing. How fast can you disable it everywhere?
Q4How do you handle link expiry and reuse of expired codes?
Q5How would you run this in two regions? What is the consistency story for a newly created link?
Q6How would you count unique visitors, not just clicks?

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.

Start free