Problem brief
A URL shortener takes a long URL and returns a short, unique alias. When a user visits the short link, the service looks up the original URL and redirects them. The mechanics fit in a sentence — which is exactly why it's a classic interview problem. The interesting decisions only surface once you push on scale, and a strong candidate is expected to uncover them rather than be handed a checklist.
New to URL shorteners? Here's the whole idea in 30 seconds.
You paste a long, ugly link into a website like bit.ly or TinyURL:
https://example.com/products/2026/summer-sale?ref=email&utm_campaign=q3
It hands you back a tiny link:
https://bit.ly/asj13
Now when anyone opens https://bit.ly/asj13, the service recognizes the short code asj13, looks up the original long URL it saved earlier, and redirects the browser there. The visitor lands on the real page — usually without noticing the hop.
Why people use it: short links are easier to share, fit in tweets/SMS/QR codes, look cleaner, and (as a bonus) let the service count clicks.
The two operations, in plain terms:
- Shorten (write): "Here's a long URL — give me a short code and remember the mapping."
- Redirect (read): "Here's a short code — send me to the original URL."
Everything else in this problem — hashing, databases, caching, sharding — exists to make those two operations fast, unique, and reliable at scale.
Practice workspace
Guided practice or a locally saved 45-minute mock.
Step 1 of 8
Functional Requirements
What are the two essential user flows, and what would you explicitly leave out of version one?
Reference solution hidden
Try answering in the workspace above first. Reveal the full worked solution — data model, architecture, and trade-offs — whenever you want to compare.
Reference solution walkthrough
The sections below are a worked solution, starting with requirements and continuing through the data model, architecture, and trade-offs. Use the practice workspace above first if you want to answer without seeing the reference.
Functional Requirements
The core features we need to support:
- Shorten a URL — given a long URL, return a short alias (e.g.
https://gdt.io/aZ8kP1). - Redirect — given a short alias, redirect to the original long URL.
- Custom aliases (optional) — let users pick their own alias when available.
- Expiration (optional) — links can expire after a configurable time.
Example. Given https://example.com/products/2026/summer-sale?ref=email&utm_campaign=q3 the service returns https://gdt.io/15ftgG. Visiting https://gdt.io/15ftgG then 302-redirects back to the original URL. That ~60-character link became 6 characters — the "short code" is the only thing we generate and store as the key.
Out of scope for a first pass: user accounts, analytics dashboards, and link editing. Call these out so the interviewer knows you're scoping deliberately.
Scale Requirements
Always anchor the design with rough numbers. A reasonable set of assumptions:
- 100M new URLs created per month → ~40 writes/sec on average.
- Read:write ratio of 100:1 → ~4,000 reads/sec, this is a read-heavy system.
- 5 years of retention → 100M × 12 × 5 = 6B URLs total.
- Each record ~500 bytes → 6B × 500B ≈ 3 TB of storage.
The big takeaways: reads dominate (cache aggressively) and storage is modest enough for a single sharded database.
Non-Functional Requirements
We pull these from adjectives in the problem statement:
- High availability — redirection must work 24/7; a dead short link is a broken promise.
- Low latency — redirects should feel instant (single-digit milliseconds).
- High durability — once created, a short URL must never be lost.
- Uniqueness — every short code maps to exactly one long URL, no collisions.
- Scalability — handle growth in both stored links and read traffic.
Availability is favored over strong consistency here: it's fine if a freshly created link takes a moment to propagate, but it's not fine if redirects go down.
Data Model
A single table is enough for the core service:
| Field | Type | Notes |
|---|---|---|
short_code | string (PK) | 7-char base62 alias |
long_url | string | the original URL |
created_at | timestamp | for retention / expiration |
expires_at | timestamp (nullable) | optional TTL |
We index on short_code (the primary lookup). Analytics (click counts) are best kept in a separate store so high-volume write traffic doesn't slow down redirects.
API Endpoints
Two endpoints cover the core flows:
POST /api/shorten
{ "long_url": "https://example.com/very/long/path", "custom_alias": "optional" }
-> 200 { "short_url": "https://gdt.io/aZ8kP1" }
GET /{short_code}
-> 302 Found
Location: https://example.com/very/long/path
Use a 302 (temporary) redirect rather than 301 (permanent) so the browser keeps hitting our service — that preserves our ability to expire links and (later) count clicks.
High-Level Design
The request flow:
- Write path — client calls
POST /api/shorten; the API server generates a unique ID, encodes it to base62, persists{short_code, long_url}, and returns the short URL. - Read path — client requests
/{short_code}; the API server checks the cache first, falls back to the database, then issues a 302 redirect.
Because reads outnumber writes 100:1, a cache (e.g. Redis) in front of the database absorbs the vast majority of traffic.
Deep Dive: Generating Unique Short Codes
The heart of the problem. We need codes that are unique, short, and ideally not trivially guessable in sequence. Walk the options and their trade-offs:
- Hashing (MD5/SHA + truncate) — hash the long URL and take the first few characters. Simple and stateless, but truncation causes collisions you must detect and retry, and the same URL always maps to the same code (which breaks per-user custom links and lets others probe whether a URL was ever shortened).
- UUID — guaranteed unique with no coordination, but 128 bits → ~22 base62 characters: far too long for a "short" URL.
- Counter + base62 encode (good default at this scale) — hand out a unique, ever-increasing integer and encode it to base62 (
[a-zA-Z0-9]). 7 characters gives 62⁷ ≈ 3.5 trillion codes. Worked example: with the alphabet ordered0-9,A-Z,a-z, the counter value1,000,000,000encodes to15ftgG(repeated division by 62); the next id1,000,000,001becomes15ftgH.
Where does the counter actually come from? This is the part interviewers push on, so be specific rather than saying "a global counter":
- Database auto-increment — a monotonic primary key from the database itself (MySQL
AUTO_INCREMENT, PostgreSQLIDENTITY/SERIAL). The row insert mints the id for free. At our ~40 writes/sec this is completely fine, and it's where I'd start. The catch is that it couples id generation to a single writer: it becomes a write-throughput ceiling and a single point of failure as you grow, and the ids are enumerable — anyone can walk15ftgG,15ftgH, … to scrape every link and infer how many URLs you've created. - Key-Generation Service (KGS) — a small dedicated service that pre-allocates ids and hands ranges (say, a block of 10,000) to each app server. Servers then generate codes locally with no per-write round trip, which removes the hot single-writer path while keeping codes compact. A KGS can also pre-generate random-looking keys so codes aren't sequential or guessable.
- Snowflake / distributed ids — the answer when a single counter genuinely can't keep up; explained next.
If sequential codes are the only problem, you don't need to change the id source — run the counter through a keyed permutation (e.g. a Feistel network or a library like hashids) so the output looks random while staying 1:1 and collision-free.
What is Snowflake? Snowflake (originally from Twitter) generates unique 64-bit ids with no shared counter, so every machine mints ids independently. The 64 bits are partitioned roughly like this:
| 1 bit unused | 41 bits: timestamp (ms) | 10 bits: machine/worker id | 12 bits: per-ms sequence |
- The timestamp makes ids roughly time-ordered — handy for sortable, index-friendly keys.
- The worker id guarantees two machines never collide, with zero coordination on the hot path.
- The sequence allows up to 4,096 ids per machine per millisecond.
Why reach for it: it removes the single-counter bottleneck and SPOF entirely — write throughput scales with the number of machines. What it costs: every machine needs a unique, stable worker id (typically assigned via ZooKeeper/etcd or config), and you must handle clock skew and backwards clock jumps or risk duplicate ids. And a 64-bit id is ~11 base62 characters — longer than a counter-based code — so for a product whose whole point is a short URL, a counter or KGS is often still the better fit. Snowflake earns its place at write volumes far above this problem's ~40/sec (think ids for tweets, messages, or events), so calling it out here is about showing you know the escalation path, not about using it on day one.
Base62 is preferred over base64 because it avoids URL-unfriendly characters (+, /, =) so codes are safe in links without escaping.
Deep Dive: Scaling the Read Path
Redirects are the hot path. To keep them fast:
- Cache aggressively — keep popular
short_code → long_urlmappings in Redis. A high hit rate means most redirects never touch the database. - Eviction — use LRU; URL access tends to follow a power-law (a few links get most of the traffic).
- Read replicas — on cache miss, read from replicas to spread load off the primary.
- CDN / edge — for globally popular links, redirects can be served close to the user.
Deep Dive: Scaling Storage
At ~3 TB over five years, storage is not the bottleneck, but plan for growth:
- Sharding — partition by
short_code(e.g. consistent hashing) so no single node holds everything. - NoSQL fit — the access pattern is a simple key lookup, so a key-value or wide-column store (DynamoDB, Cassandra) fits naturally and scales horizontally.
- Cleanup — a background job purges expired links to reclaim space.
Wrap-Up & Trade-offs
A solid answer hits these points:
- Counter + base62 for short, collision-free codes — a plain DB sequence is fine at this write volume; reach for a KGS or Snowflake only when writes outgrow a single writer.
- Cache-first reads because the system is overwhelmingly read-heavy.
- 302 redirects to retain control over expiration and future analytics.
- Separate analytics store so click tracking never slows the redirect path.
- Favor availability over strong consistency for redirects.
Common follow-ups: how to prevent abuse/spam links, how to keep short codes from being enumerable/guessable, how to handle custom-alias collisions, and how to add per-link analytics without hurting latency.