Understanding the problem
Bit.ly is a URL shortening service: users paste a long URL, get a short link, and anyone who opens the short link lands on the original. Many products also sell analytics — we'll keep that below the line so you learn the core design first.
Create + redirect · alias/expiry optional.
POST /urls · GET /{code}.
Hash vs counter · UNIQUE net.
Cache · read/write split.
Pair with Delivery framework, Scaling reads, Caching, Indexing, and Redis.
Functional requirements
Start every interview by pinning what the system must do for users. Interviewers sometimes hand you the list; often you propose it. Zero in on the top 3–4 features — don't get lost in bells and whistles.
Non-functional requirements
NFRs are how the system must operate — latency, availability, scale — not features. Frame them as benchmarks.
Core entities
Start with a broad list — not every column. Tell the interviewer you'll refine the schema in high-level design.
- Original URL — the long URL the user wants to shorten.
- Short URL / short code — the compact code (and full short link on your domain).
- User — who created it (even if auth is out of scope, the entity often appears).
- ClickAnalytics — below the line, but naming it (clicks, geo, referrer, tied to ShortURL 1:N) shows you scoped it rather than forgot it.
The API
APIs are the contract between client and server. Walk functional requirements one-by-one. In interviews, default to REST and pick the right verb: POST create, GET read, PUT update, DELETE delete.
POST /urls
{
"long_url": "https://www.example.com/some/very/long/url",
"custom_alias": "optional_custom_alias",
"expiration_date": "optional_expiration_date"
}
→
{
"short_url": "http://short.ly/abc123"
}
We use POST because we create a new mapping row. Optional fields stay optional.
GET /{short_code}
→ HTTP 302 Found
Location: https://www.example.com/some/very/long/url
GET is correct — we're reading an existing mapping. Status code details (301 vs 302, 410 Gone for expired) land in the high-level design.
High-level design — create a short URL
Design one functional requirement at a time. First: users submit a long URL and get a short one.
- Client — web or mobile app.
- Primary server — validation, code generation, business rules.
- Database (
Urls) — short code (or custom alias), original url, creationTime, expirationTime?, createdBy.
- Client sends
POST /urlswith long URL (± custom alias, expiry). - Server validates URL format (simple library / regex is fine in an interview).
- Dedup? Optional — return existing code for the same long URL. Most products allow many shorts for one long URL (different expiry, analytics, aliases). Say the trade-off: storage vs product flexibility.
- Generate a short code (abstracted for now) — or use the custom alias after uniqueness check. Prefix/namespace generated codes so they don't collide with custom aliases.
- Insert mapping; return
http://short.ly/{code}.
High-level design — redirect
The short link lives on your domain — e.g. short.ly/abc123 always hits your servers first.
- Browser
GET /abc123. - Server looks up
abc123(cache, then DB). - If found and not expired → return redirect to long URL. If expired → 410 Gone.
- Browser follows
Locationautomatically — users barely notice the hop.
301 vs 302
- 301 Permanent — browsers often cache; later visits may skip your server entirely.
- 302 Found — typically not cached that aggressively; every click can hit you first.
Cleanup: periodic job to delete expired rows or leave them and check expires_at on read. Set cache TTL ≤ expiration so stale shorts don't live in Redis after they should die.
Deep dive — unique short codes
Constraints: codes must be unique, as short as practical, and cheap to generate.
Option A — Hash the long URL
Hash (MD5/SHA), take a prefix, base62-encode. Pros: no central counter. Cons: collisions — on UNIQUE violation, lengthen prefix or retry with salt. Same long URL always hashes the same (dedup-friendly) unless you salt per user.
input_url = "https://www.example.com/some/very/long/url"
canonical_url = canonicalize(input_url) # lower host, strip default ports
hash_code = hash_function(canonical_url)
short_code = base62_encode(hash_code)[:8] # 8-character short code
Option B — Global counter + base62 (recommended teaching default)
Atomically increment a counter, convert to base62 (0-9a-zA-Z). Guarantees uniqueness if the counter is correct; codes stay short. Needs coordination when you scale writers.
Option C — Custom alias
User-chosen code after availability check. Keep a separate namespace or reserved prefix so aliases never collide with future counter values.
Deep dive — fast redirects (scale reads)
Without an index, lookup is a full table scan — unusable at millions of rows. With a primary key on short_code, DB lookup is fine for moderate QPS — but at redirect scale you still want a cache.
- Cache TTL aligned with expiry (or shorter).
- On create/update/expire — invalidate or set the key.
- Hot viral codes stay in memory — that's the win.
- Code generation can start as a random/counter box on the server — upgrade to Redis counter when you split writers.
Deep dive — scale to 1B URLs and 100M DAU
Storage napkin: ~200–500 bytes/row × 1B ≈ ~500GB — within one modern Postgres on good disks. Writes ~100k creates/day ≈ ~1/sec average — almost any serious DB works. Pick what you know; Postgres is a fine interview default.
If the DB dies: replication (hot standby) and backups. Mention them; don't boil the ocean.
Split read and write services
Because traffic is asymmetric, separate Write service (POST /urls) from Read service (GET /{code}). An API Gateway routes to the correct microservice. Scale each fleet independently.
The counter problem
Many write instances need one source of truth for the next ID — the Global Counter (Redis INCR). One extra network hop per create is usually fine at ~1 write/sec — but still teach the upgrade:
- Counter batching — each writer claims 1000 IDs (
INCRBY 1000), uses them locally, then claims again. Fewer Redis RTTs; gaps OK. - HA — Redis Sentinel/Cluster failover. Lost unreplicated counter values are OK if UNIQUE still holds.
- Multi-region — allocate disjoint ranges (region A: 0–1B, B: 1B–2B) so regions don't coordinate on every write. Reads served from regional caches.
Cost and performance levers
What interviewers expect by level
URL shortener is often labeled "entry-level," but the bar still rises with seniority.
Architecture by level
Three progressive sketches — beginner spine, mid optimizations, pro production depth. Use the matching diagram in the interview; say the tradeoff out loud.
Pros: Tiny surface — create + redirect + UNIQUE code is enough to pass an entry screen. Cons: One process + one DB becomes the bottleneck under read-heavy redirects; no shared counter means uniqueness breaks the moment you add a second write replica.
Pros: Cache absorbs redirect QPS; Redis counter coordinates IDs across write instances; R/W split matches the 100:1 traffic shape. Cons: Cache invalidation / TTL for expiry adds edge cases; you're now operating Redis HA — a failover that replays ID ranges can create duplicate codes if claim isn't durable.
Pros: Multi-region ID ranges and CDN cut latency worldwide; replicas and failover story show production judgment. Cons: Complexity tax — custom aliases, analytics, and security of predictable codes compete for interview time; over-building multi-region before proving single-region p99 is a common fail.
Entry: working spine. Mid: trade-offs without prompting. Principal: production and evolution. Match depth to the bar.
Wrapping up
A URL shortener teaches the delivery framework end-to-end: clarify requirements, sketch entities and APIs, build a simple HLD, then deep-dive uniqueness, read scaling, and write coordination. Keep analytics and auth below the line until asked.
Related: Delivery framework · Design Dropbox · Scaling reads · Caching · Redis · PostgreSQL · Common patterns.