Notes
Listen
Start by listening and pinning down scope. Two core flows: turn a long URL into a short link, and redirect anyone who clicks it. Nice to have: a custom alias, an expiry date and a click count. Non-functional: redirects must be fast, say under fifty milliseconds at p ninety-nine, highly available, and two links must never share a code.
Say out loud what you are leaving out: accounts and billing, editing links after creation, a full analytics dashboard, QR codes. Then ask a clarifying question or two. Are users global? Yes, but one region for writes is fine, since creating links is rare compared with following them. Naming the scope lets the interviewer redirect you early instead of after twenty minutes.
- functional:
- long URL → short link
- short link → redirect
- optional custom alias
- optional expiry date
- click count per link
- non-functional:
- redirect p99 under 50 ms
- 99.99% available redirects
- codes never collide
- accounts, teams, billing
- editing a link after creation
- a full analytics dashboard
- (we only count clicks)
- QR codes, link previews
- ask: global or one region?
- → global users, one write
- region is fine
Ask scale
Ask about scale. Assume a hundred million new links a month. A month is about two and a half million seconds, so that is forty writes a second, about a hundred twenty at peak. With a hundred reads per write, redirects run at four thousand a second, twelve thousand at peak. At roughly five hundred bytes a row, that is six hundred gigabytes a year, about three terabytes over five years.
Now turn numbers into decisions. Five years of links is about six billion codes. Base sixty-two, lowercase, uppercase and digits, gives fifty-seven billion six-character codes and three and a half trillion seven-character ones, so seven characters leaves huge headroom. The hot set of popular links is a few million rows, a few gigabytes, so it fits in one Redis cluster. This is a read-heavy system with tiny writes.
new links: 100M / month 100M / 2.5M s ≈ 40 writes/s peak ×3 ≈ 120 writes/s reads 100:1 → 4,000 redirects/s peak ≈ 12,000 redirects/s row ≈ 500 bytes 100M × 12 × 500 B ≈ 600 GB/yr 5 years ≈ 3 TB (plus replicas)
code length, base62 (a-z A-Z 0-9): 62^6 ≈ 57 billion 62^7 ≈ 3.5 trillion need 6 billion over 5 years → 7 chars, lots of headroom cache: 20% of links get most clicks; hot set of a few million rows ≈ a few GB → read-heavy, tiny writes
Define API
Define the API. A POST to create a link, taking the long URL and an optional alias and expiry, returning the code and the short URL. A GET on the code that answers with a redirect status and a Location header pointing at the long URL, or four hundred four if it is unknown or expired. And a small stats endpoint for the click count. Rate limit the create endpoint per user or IP.
POST /v1/urls
{ long_url, alias?, expires_at? }
→ 201 { code: "aZ3k9Qx",
short_url }
GET /{code}
→ 302 Location: long_url
→ 404 if unknown or expired
GET /v1/urls/{code}/stats
→ { clicks } Data model
The data model is one main table keyed by the code, with the long URL, an optional owner, and created and expiry times. Click counts live separately so the hot redirect row is never updated. Every read is a lookup by primary key, so a key-value store like DynamoDB fits well. Postgres also works: three terabytes over five years, sharded by code later if needed. Pick one and say why.
urls code PK varchar(10) long_url text owner_id nullable created_at, expires_at click_counts code PK, clicks bigint access: always by code → key-value lookup
High-level design
Draw the high-level design. Users hit a load balancer, then a stateless shortener service. To create a link, the service takes the next unique number from its local block of IDs, encodes it in base sixty-two to get a seven-character code, inserts the row, and returns the short link. The ID range service is only contacted occasionally to fetch a new block, which we will expand on shortly.
Now trace a redirect, the hot path. The request lands on any service instance, which checks Redis for the code. On a hit, it returns the redirect in a couple of milliseconds. On a miss, it reads the database, fills the cache and redirects. With a ninety percent hit ratio, only about twelve hundred reads a second reach the database at peak, which a single primary with replicas handles easily.
Deep dive: codes
Deep dive one: generating codes. Option one, hash the long URL and keep seven characters; but truncated hashes collide, so you must check and rehash. Option two, pick seven random characters and insert only if absent; simple, but every create needs a uniqueness check. Option three, a counter encoded in base sixty-two; it never collides, but a single global counter is a bottleneck and the codes are guessable.
The fix is ranges. A small range service, just a database row or ZooKeeper, hands each server a block of a thousand IDs by advancing a counter. The server then counts inside its block in memory, with no network call per link. A crash wastes at most a thousand IDs, nothing against three and a half trillion. If codes must not be guessable, pass the number through a reversible scramble before encoding.
- 1 hash the URL (MD5/SHA),
- take 7 base62 chars
- - collisions: check + rehash
- 2 random 7 chars, insert if
- absent
- - a DB check per create
- 3 counter → base62
- + no collisions, ever
- - one counter = bottleneck
- and guessable
range service (a DB row or ZooKeeper) hands out blocks: UPDATE next = next + 1000 server counts inside its block in memory: no network call crash wastes up to 1,000 ids: fine with 3.5 trillion unguessable? scramble the id with a reversible permutation
Deep dive: redirects
Deep dive two: which redirect status. A 301, moved permanently, lets browsers cache the redirect, so repeat clicks never reach you. Less load, but you lose click counts and cannot expire or disable a link. A three oh two sends every click through you. Since we want click counts and expiry, choose three oh two, and absorb the load with Redis and a short edge cache for very hot links.
Click counting must stay off the hot path. Incrementing a counter row inside each redirect would turn a viral link into a hot row. Instead, emit a click event to Kafka and return immediately. A consumer aggregates counts per code per minute and writes the totals, so fifty thousand clicks a second on one link becomes one write a minute. Also cache not-found results briefly, so scanners guessing codes do not hammer the database.
- 301 Moved Permanently:
- browsers cache it, later
- clicks never reach us
- + less load, - no counts,
- cannot expire the link
- 302 Found (or 307):
- every click comes to us
- + accurate counts, expiry
- choice: 302, cache in Redis
- and at the CDN edge
- never UPDATE clicks = clicks+1
- inside the redirect
- emit a click event to Kafka
- (fire and forget)
- consumer aggregates per code
- per minute, writes totals
- viral link: 50k clicks/s
- → one write per minute
- unknown codes: cache the 404
- briefly (stops scans)
Deep dive: storage
Deep dive three: the store. At a hundred and twenty writes a second and three terabytes over five years, one primary with a couple of read replicas is honestly enough; the replicas absorb cache misses and take over if the primary fails. When it outgrows that, shard by a hash of the code, so every lookup still touches exactly one shard. Do not range-shard by code: codes from a counter are sequential, so every new link would land on the last shard.
- today: 120 writes/s peak, 3 TB
- in 5 years → one primary +
- read replicas is enough
- replicas take cache misses
- and cover primary failure
- later: shard by hash(code)
- every lookup hits 1 shard
- never range-shard by code:
- new sequential codes would
- all land on the last shard
Deep dive: edge cases
A few edge cases. Custom aliases are inserted with the alias as the primary key, so a duplicate fails with a conflict; keep aliases from colliding with generated codes, for example by reserving a prefix. Expiry is checked on read, cache entries never outlive it, and a nightly job deletes expired rows. And shorteners attract abuse, so scan new URLs against malware lists and rate limit creation per user and IP.
- custom alias: INSERT with the
- alias as PK; conflict → 409
- keep aliases out of the
- counter space (min length)
- expiry: check expires_at on
- read; nightly job deletes
- rows; cache TTL ≤ expiry
- abuse: scan new URLs against
- a malware list; rate limit
- creates per user and IP
Trade-offs
Review the trade-offs. The bottleneck is the redirect read path, so it gets the cache, replicas and edge caching. If Redis dies, the database must survive the full load, so size replicas for it. If the range service is down, servers keep using their current blocks for a while. Monitor redirect latency, cache hit ratio, the four hundred four rate and consumer lag. At ten times the scale, shard by code and add read replicas in more regions.
- bottleneck: redirect reads
- → cache, replicas, edge
- Redis down → DB takes all
- reads: replicas sized for it
- range service down → servers
- keep using their blocks
- monitor: redirect p99, hit
- ratio, 404 rate, create
- errors, Kafka lag
- 10x: shard by code, multi-
- region read replicas
Interview summary
The thirty-second summary. Forty writes and four thousand reads a second, three terabytes over five years. Create with a POST, redirect with a GET returning three oh two. One table keyed by a seven-character code. Codes come from ID blocks encoded in base sixty-two, so there are no collisions and no hot counter. Redirects read Redis then the database. Clicks flow through Kafka into per-minute totals.
- 40 writes/s, 4k reads/s, 3 TB
- POST /urls, GET /{code} → 302
- table keyed by 7-char code
- codes: ID blocks → base62,
- no collisions, no hot counter
- redirect: Redis → DB, 302 for
- counts and expiry
- clicks: Kafka → per-minute
- totals
- aliases via unique key