A few weeks ago, a friend of mine — three years into his backend career, preparing hard for an SDE role at a company most of us would kill to get into – called me in a slight panic.
“They asked me to How to Design a URL Shortener Like TinyURL,” he said. “I’ve used it a hundred times. I froze.”
That’s the thing about this question. It sounds trivial. Paste a long link, get a short one back. What’s there to design? But that’s exactly why interviewers love it — it’s simple enough to explain in one sentence, and deep enough to fill a 45-minute interview if you know where to dig.
This is the walkthrough I wish someone had given him — not a dry spec sheet, but the story of what’s actually being tested at each step, and where most candidates quietly lose points.
Nailing the Requirements
Every senior candidate makes the same first move: they don’t touch the whiteboard until the requirements are locked in. A design built on the wrong assumptions falls apart no matter how elegant it looks.
Functional requirements — what it must do
- Shorten it. Submit a long URL, get back a short, unique alias.
- Redirect it. Visit the short URL, land on the original long URL.
Non-functional requirements — what it must be
- Highly available. If this goes down, every short link pointing through it breaks at once — a very public failure.
- Scalable. Traffic spikes hard when a single link goes viral.
- Low latency on redirects. Nobody notices a URL shortener that works. Everybody notices one that takes three seconds.
Interview tip — always ask whether custom aliases, expiry, and click analytics are in scope. Bolting them on later, unasked, is how "below the line" features quietly become feedback against you.
Back-of-the-Envelope Estimation
This is the part candidates rush — and the part that silently justifies everything that follows. Skip it, and your caching strategy looks like a guess instead of a decision.
- Assume 100 million new short URLs per month.
- Over 5 years: 100M × 12 × 5 = 6 billion records.
- At ~500 bytes/record: 6B × 500B ≈ 3 TB total storage. Very manageable.
- Read:write ratio ≈ 100:1 — URL shorteners are read-heavy by nature.
- 200M writes/month → 200M × 100 = 20 billion reads/month.
That last number is the whole ballgame. Twenty billion reads a month is what forces caching, read replicas, and horizontal scaling into the conversation — not because “caching is best practice,” but because the math demands it. Say that out loud; it shows reasoning, not memorization.
API Design
Create a short URL
POST /api/v1/shorten Content-Type: application/json { "long_url": "https://example.com/very/long/path/to/resource", "custom_alias": "my-link", // optional "expires_at": "2026-12-31T00:00:00Z" // optional }
{
"short_url": "https://tinyurl.com/abc123",
"short_code": "abc123",
"expires_at": "2026-12-31T00:00:00Z"
}| Status | Meaning |
|---|---|
| 400 | Bad Request — invalid URL format |
| 409 | Conflict — custom alias already taken |
| 429 | Too Many Requests — rate limit exceeded |
Redirect to the original URL
GET /abc123Location: https://example.com/very/long/path/to/resource| Status | Meaning |
|---|---|
| 404 | Not Found — short code doesn’t exist |
| 410 | Gone — link has expired |
Why 302, not 301?
A favorite interviewer follow-up. 301 (Permanent) gets cached by the browser forever — fast, but you lose the ability to track clicks or change the destination. 302 (Temporary) means the browser asks your server every time — slightly more load, but you keep click analytics and the ability to kill or redirect a link at any time. Most production shorteners use 302 for exactly this reason.
Designing the Database Schema
Before drawing boxes and arrows, nail down what’s actually being stored — interviewers often ask you to sketch this explicitly, and it directly feeds the SQL-vs-NoSQL question later.
Core table — urls
| Column | Type | Notes |
|---|---|---|
| id | BIGINT (PK) | Snowflake-generated unique ID — the value that gets Base62-encoded into the short code |
| short_code | VARCHAR(10) | Unique index. Base62 string or custom alias — the lookup key on every redirect |
| long_url | VARCHAR(2048) | The original URL |
| is_custom_alias | BOOLEAN | User-chosen vs. system-generated |
| user_id | BIGINT, null | Only needed if accounts/ownership are in scope |
| created_at | TIMESTAMP | |
| expires_at | TIMESTAMP, null | Drives the 410 check on redirect |
The single most important decision here: short_code needs a unique index. Every redirect does an exact-match lookup on it, and the same index is what enforces "custom alias already taken" at the database level — cheaper and safer than a check-then-insert in application code.
Optional — click_events (only if analytics is in scope)
| Column | Type | Notes |
|---|---|---|
| id | BIGINT (PK) | |
| short_code | VARCHAR(10) | Indexed, references urls.short_code |
| clicked_at | TIMESTAMP | |
| referrer | VARCHAR(255), null | |
| geo | VARCHAR(100), null |
Keeping this as a separate table — fed asynchronously via a queue, as covered in the scenario questions below — means the hot redirect path never writes to it synchronously.
SQL or NoSQL for urls?
Either is defensible in an interview, but the reasoning matters more than the pick. NoSQL (DynamoDB, Cassandra) is favored when the priority is horizontal write scale and built-in replication at billions of rows — short_code becomes the partition key, giving O(1) lookups since the schema has no relationships to speak of. SQL is favored when you want a database-enforced unique constraint on short_code and simple ACID guarantees around alias creation, at the cost of planning sharding yourself as the table grows.
High-Level Architecture
Here’s how a request actually moves through the system – the write path in amber, the read path in teal, kept deliberately separate because writes are rare and reads are relentless.
The write path: load balancer → write service → ID generator (unique numeric ID → Base62 short code) → primary database → short URL returned.
The read path: load balancer → read service → Redis. Cache hit → redirect immediately, sub-10ms. Cache miss → read replica (never the primary — that’s asking for a bottleneck) → check expiry (410 if expired, queued for async cleanup) or missing (404) → on success, write back to Redis with a TTL, then return 302.
That write-back-on-miss step is what keeps the cache warm without pre-populating it: popular links stay fast, forgotten ones simply age out.
How Do We Actually Generate the Short Code?
This is where an interviewer usually leans forward — there’s no single right answer, only trade-offs.
- MD5 / hash-based — simple, but collisions are a real risk at billions of records; needs a resolution strategy.
- Random string — generate, check DB for collision, retry — but that check-then-retry loop adds latency and doesn’t parallelize cleanly.
- Base62(unique ID) — the common answer. Generate a globally unique, ordered ID (often via Snowflake: timestamp + machine ID + sequence, 64 bits), then encode it with the 62 characters
[0-9, a-z, A-Z].
Base62-over-Snowflake wins most interviews because it sidesteps collisions entirely — the ID is already unique before it’s ever encoded — and needs no coordination lookup before writing, which is exactly what a distributed, high-write system wants.
Caching — Where the Real Scale Lives
Remember the 20-billion-reads-a-month number? This is where it gets absorbed.
- Redis as key-value store:
short_code→long_url. - LRU eviction — rarely-clicked links get pushed out first.
- TTL per entry — stale or expired links don’t linger.
- Clustering across nodes for capacity and fault tolerance — no single box holding it all.
A useful mental check: if a redirect resolves in under ~10ms, that's almost certainly a cache hit. Noticeably slower means a cache miss fell through to the database — worth mentioning when discussing observability.
Scenario-Based Interview Questions (Actually Asked at FAANG/MAANG)
Reading the design is one thing. Defending it under follow-ups is where interviews are won or lost. Here’s how to think through the ones that come up most.
Q1 Two users submit URLs at the exact same millisecond and collide on the same short code. What now?
Tests whether you understand why Base62-over-Snowflake avoids this at the source — uniqueness comes from ID generation, not a database-level check. With hashing instead, the right answer is retry-with-suffix backed by a DB uniqueness constraint, with the added latency acknowledged.
Q2 A single link gets 5 million clicks in an hour after going viral. Walk me through it.
The hot key problem. A naive cache can still get hammered on the same node, or the same DB row if TTL expires mid-spike. Strong answers mention extending TTL for popular keys, replicating a hot key across cache nodes, or a CDN layer for extremely popular redirects.
Q3 Two simultaneous requests for the same custom alias. How do you guarantee only one wins?
Tests whether you reach for a unique constraint on the alias column plus a transaction, instead of a “check-then-insert” pattern in application code — which has an obvious race condition between the check and the insert.
Q4 SQL or NoSQL for the core mapping table — and why?
No single right answer, but you’re expected to reason it through. NoSQL (DynamoDB, Cassandra) is often favored for horizontal write scale and built-in replication at this volume; SQL wins if you need strong transactional guarantees around alias uniqueness. The trade-off reasoning is what’s scored.
Q5 Redis restarts and the cache is completely empty. What happens, and how do you prevent a meltdown?
The cold cache / thundering herd problem — every read falls through to the DB at once. Good answers mention gradual cache warming, request coalescing (one request per key hits the DB while others wait on that result), and read replicas absorbing the spike.
Q6 How would you add click analytics — count, geography, referrer — without slowing the redirect down?
Tests whether you keep the hot redirect path untouched and instead fire-and-forget an event to a queue (Kafka), feeding a separate analytics pipeline — never writing analytics synchronously inside the read path.
Q7 How do you actually invalidate expired links at scale, across millions of rows?
Tests whether you reach for an async cleanup job (cron sweep or queue-based deferred deletion) rather than checking expiry synchronously on every single read — and whether you remember to invalidate the cache entry too, not just the DB row.
Q8 How would you shard the database as it outgrows a single primary?
Tests sharding-key intuition — typically sharding by a hash of the short_code so lookups route deterministically, versus sharding by creation time, which would concentrate all recent (and hottest) traffic on a single shard.
Final Thoughts
The URL shortener question isn’t hard because the system is complex — it’s hard because it’s deceptively simple, and simplicity is exactly what tempts candidates into skipping the reasoning interviewers are listening for. Nail the requirements, justify your numbers, and defend every trade-off — hashing vs. Snowflake, SQL vs. NoSQL, 301 vs. 302 — and this stops being a question to fear and becomes one of the easiest wins in your loop.
Next up: a rate limiter or a distributed cache — both share a lot of DNA with what we just built here.
Got a scenario question from a real interview that isn't on this list? Drop it in the comments — the best ones get added.