Minute 0–5: scope the problem
Do not start drawing boxes. Ask questions. The interviewer is watching to see if you can narrow down requirements.
- Is this for public use (Bit.ly) or internal (Slack link unfurls)?
- Do URLs expire? Custom aliases?
- What is the read:write ratio? (For shorteners: often 100:1 reads to writes.)
- Do we need analytics?
- Which regions?
State your assumptions aloud: "I will assume public, 100M new URLs per month, 10B reads per month, no expiry, optional custom aliases, global."
Minute 5–10: back-of-envelope
- 100M writes/month ≈ 40 writes/sec average, ~200/sec peak.
- 10B reads/month ≈ 4k reads/sec average, ~20k/sec peak.
- Storage: if each record is ~500 bytes, 100M/month × 12 months × 10 years = 12B records × 500B = 6TB. Fits comfortably in a sharded database.
- Short code length: base62 gives 62^7 ≈ 3.5 trillion codes in 7 characters. Plenty.
Minute 10–15: API and data model
POST /shorten { long_url, custom_alias? } -> { short_url }
GET /:code -> 302 redirect
Data model (one table):
urls
code (PK, varchar 10)
long_url (text)
created_at
user_id (nullable)
Indexes: primary on code. That is the only lookup path. Simple.
Minute 15–25: the code generation strategy
This is where candidates reveal themselves.
Option A: hash the URL. Take md5(long_url) and base62-encode the first 7 chars. Problem: collisions. Two different URLs can hash to the same prefix. You need to check-then-insert, which does not work under concurrency without locks.
Option B: random generation with collision retry. Generate 7 random base62 chars, attempt insert, on conflict retry. Works but gets worse as space fills.
Option C: counter + base62 encode. A distributed counter service (e.g., ZooKeeper, or a dedicated Postgres sequence partitioned across shards) hands out batches of IDs. Each ID is base62-encoded to produce the code. No collisions, ever. This is the approach I would advocate.
Discuss the trade-off: counters are predictable (people can enumerate your URLs). If that matters, XOR the counter with a secret before encoding. Bit.ly-class systems care; internal shorteners usually do not.
Minute 25–35: read path and caching
20k reads/sec is well within Redis territory. Put a cache in front of the DB:
- GET
:code→ Redis lookup. - Hit: return the long URL, return 302.
- Miss: hit Postgres, populate Redis with a TTL (say 24h), return 302.
With a hit rate of 95%+ (common for URL shorteners due to hot tails), the DB sees ~1k reads/sec. Trivial load.
Mention CDN for the actual 302 response if latency is a concern globally. Many shorteners run the redirect at the edge.
Minute 35–40: write path and consistency
Writes are cheap in this system. 200 writes/sec hits a single well-tuned Postgres happily. Sharding can wait until much later.
Acknowledge replication lag: if a user creates a URL and clicks it immediately, do we guarantee they can? Primary-read for the creating user's session, replica-read for everyone else.
Minute 40–45: scale and failure modes
- Sharding: shard by
code's hash when a single DB is full. All lookups have the code already, so routing is trivial. - Analytics: async log to Kafka, aggregate in a data warehouse. Do not write click counts back to the main DB — hot rows will kill you.
- Cache eviction: hot URLs stay in cache; long tail evicts. Monitor hit rate.
- Abuse: rate-limit
POST /shorten, scan for known-malicious destinations.
The evaluation
Interviewers want to see: clear scoping, honest estimation, sane data model, articulated trade-offs on ID generation, and a plan for reads that matches the read-heavy workload. Get those right and you pass regardless of which specific database you picked.
