Design a URL shortener
Turn https://example.com/a/very/long/path?with=parameters into sho.rt/x7Kp2Q, and send anyone who opens the short link to the long one, quickly, for years. It's the classic first design: small enough to finish, but with real depth in generating codes, serving a flood of reads, counting clicks without slowing anything down, and keeping printed links alive. It follows the interview framework phase by phase.
1. The prompt
"Design a URL shortener like bit.ly or TinyURL. Users paste a long URL and get a short one; anyone opening the short URL is sent to the original. Link owners want to see how often their links are clicked."
The write side is tiny, and the read side is a flood that must never break, because short links end up printed on posters, packaging and business cards. Most of the interview is about the redirect path: how codes are made, how redirects stay fast and available, and how analytics stay out of the way.
2. Frame the problem
Ask these first. Each answer changes something specific; guess before you open them.
How many links are created, and how often are they opened?
About 100M new links a month, and roughly 100 redirects for every link created.
Writes are small (~40 a second); reads are 100× larger and bursty. The design is read-optimised.
Can users choose their own alias (sho.rt/spring-sale)?
Yes, optionally.
Aliases must be checked for uniqueness atomically, and must never clash with generated codes.
Do links expire?
Optionally, at a date the owner chooses. Most never expire.
Reads must check expiry, and something must clean up expired links without slowing anything.
Can the destination of a link be changed later?
Not in this version.
Immutable links can be cached forever, which makes the cache trivially correct.
What analytics do owners need?
Click counts per link, by day; country and referrer would be nice. A minute of delay is fine.
Analytics can be asynchronous, so counting stays out of the redirect path.
How short should codes be, and should they be guessable?
As short as practical, around 7 characters. Codes shouldn't be easy to walk through one by one.
This decides how codes are generated (section 9.1).
What about abuse?
Shorteners are used to hide phishing and malware. We must be able to block a link quickly.
We need a fast "disable" path through every cache layer, and checks when links are created.
3. What it must do
- A user can create a short link for a long URL, optionally with a custom alias and an expiry date.
- Anyone opening a short link is redirected to the long URL.
- The owner can see click counts for their links.
Not now:
- Editing a link's destination: it would break caching assumptions; add it later with versioned cache keys.
- QR codes: rendering only, no design impact.
- Teams, billing and accounts management: standard product plumbing.
- A/B routing: a different product.
4. How well it must do it
| Target | Why this number |
|---|---|
| Fast redirects: p99 under 50 ms at the server speed | A redirect is pure overhead before the real page loads. |
| Redirects always work: 99.99% available; availability beats consistency (a new link taking a second to work everywhere is fine) consistency or availability · surviving failures | A broken short link breaks every page, email and poster it's printed on. |
| Never lost, never shared: a created link works for years; two links never get the same code data safety | Links outlive campaigns; reusing a code would send people to the wrong place. |
| Not enumerable: codes can't be walked one by one to discover private links security | People shorten private documents and unlisted pages. |
| Read-heavy: ~100 redirects per created link, with viral spikes load shape | One link shared by a celebrity can get thousands of clicks a second. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| New links | 100M a month ÷ (2.5 × 10⁶ s) | ~40/s, ~120/s at peak → so one database handles writes easily |
| Redirects | 100 × writes | ~4,000/s, ~12,000/s at peak → so we cache hot links |
| Storage | 100M × 12 months × 5 years × ~500 bytes | 6 billion links ≈ 3 TB → so one large database or a key-value store; no sharding on day one |
| Code length | 62 characters (a–z, A–Z, 0–9); 62⁷ ≈ 3.5 × 10¹² | → so 7 characters lasts centuries at this rate (check with the calculator in section 13) |
| Cache | ~350M redirects a day, mostly for recently shared links: say 50M hot links × 500 bytes | ~25 GB → so one Redis cluster holds the hot set |
6. The nouns
- Link: a short code and the URL it points to.
- User: the owner.
- Click: one redirect, as an event.
- Daily count: clicks aggregated per link per day, for the stats page.
links code (primary key), long_url, owner_id, created_at, expires_at, status (active | disabled) click events code, time, referrer, country (a stream, not a table updated per click) click_counts code, day, clicks (written by the aggregation job)
7. The contract
REST. The owner comes from the auth token. The redirect is a plain HTTP GET on the short domain, because that's what browsers do.
POST /v1/links Idempotency-Key: … {long_url, custom_alias?, expires_at?}
→ 201 {code: "x7Kp2Q", short_url: "https://sho.rt/x7Kp2Q"} (409 if the alias is taken)
GET /{code} → 302 Found, Location: https://example.com/a/very/long/path…
(404 if unknown, 410 Gone if expired or disabled)
GET /v1/links/{code}/stats → {clicks_total, clicks_by_day: [...]} (owner only)
8. First working version
Stateless app servers behind a load balancer and one database. Build each request, then note what will break.
8.1 Creating a link
For the first version, generate 7 random base62 characters and insert with the code as the primary key; if the insert fails because the code exists, generate another. A custom alias uses the same insert, and the primary key rejects one that's taken.
8.2 Redirecting
Look the code up by primary key, check status and expiry, and answer 302 with the long URL in Location.
8.3 Counting clicks (version one)
To start, each redirect also runs UPDATE click_counts SET clicks = clicks + 1 WHERE code = ? AND day = ?, and the stats page reads those rows.
Parked for later (section 9 fixes each)
- Random codes collide more often as the table fills.
- Every redirect reads the database, so a database blip breaks every link.
- A write on every redirect puts row contention on viral links into the fastest path.
- Should the redirect be 301 or 302?
- Aliases, expiry and abuse need rules.
- One region: a regional outage breaks every printed link.
9. Make it hold up
9.1 Generating unique short codes never shared · not enumerable
| Approach | Why | |
|---|---|---|
| ✗ Bad | Hash the long URL (MD5) and take the first 7 characters | Different URLs can collide, and the fix (check, then rehash) is messy; the same URL from two users gets the same code, leaking who else shortened it and breaking per-owner stats. |
| ~ Good | 7 random base62 characters, insert with a unique constraint, retry on collision | Simple and unguessable. Collisions are rare while the table is small but grow as it fills (the birthday problem), so later inserts may need retries. |
| ✓ Great | A counter, encoded in base62. Each write server leases a block of 1,000 IDs at a time from a central counter (a database sequence or Redis INCR), so the counter isn't touched per request. Scramble the number with a reversible permutation before encoding so codes don't look sequential. | Guaranteed unique, no collision checks, short codes, and the counter is hit only once per 1,000 links. A server that crashes wastes the rest of its block, which is fine. |
Try both calculators in section 13: how long codes last, and what a scrambled counter looks like.
9.2 Redirects in under 50 ms, at 12,000 a second fast · always work
| Approach | Why | |
|---|---|---|
| ✗ Bad | Every redirect reads the database | Works at first, then the database becomes the bottleneck at peaks, and a database blip breaks every link. |
| ~ Good | Cache-aside in Redis: code → long_url, LRU eviction, entries never stale because links don't change | Most redirects are for a small set of popular links (a Zipf distribution), so a modest cache serves the large majority in under a millisecond. |
| ✓ Great | Cache, plus redirects answered at the CDN edge for popular links (short cache lifetimes), an in-process cache for the very hottest codes, and read replicas or a replicated key-value store behind it | A viral link is served from edges near users; the origin sees only misses. Redirects keep working even if the primary database is down. |
Keep the create path and the redirect path as separate services. They have different traffic, different scaling and different failure tolerance, and the redirect service should have no dependency it doesn't absolutely need.
9.3 301 or 302? fast · analytics
A 301 Moved Permanently lets browsers and proxies cache the redirect, so repeat visits never reach you: less load, but you can't count those clicks or block the link later for people who already have it cached. A 302 Found (or 307) is checked each time: you see every click, at the cost of more traffic. Shorteners that sell analytics use 302, with a short Cache-Control lifetime so CDNs can still absorb spikes. Say which you chose and why.
9.4 Counting clicks without slowing redirects fast · analytics
| Approach | Why | |
|---|---|---|
| ✗ Bad | UPDATE … clicks = clicks + 1 on every redirect | Puts a write (and row contention on viral links) in the hottest read path. |
| ~ Good | Increment a counter in Redis; flush to the database every minute | Fast, but a cache failure loses counts, and it only gives totals. |
| ✓ Great | The redirect service emits a click event to Kafka after responding (fire-and-forget, with a local buffer); a stream job aggregates counts per link per minute into an analytics store | Redirects stay fast; detailed stats (by day, country, referrer) come for free; the raw log can be reprocessed. Counts are seconds behind, which the requirements allow. Design D17 goes deeper. |
Clicks answered at the CDN edge never reach the origin; count them from CDN logs, merged into the same pipeline.
9.5 Aliases, expiry and abuse never shared · security
- Custom aliases go in the same table; the primary key on
coderejects duplicates atomically (a conditional insert, Module 17). Reserve words likeapiandlogin, and keep aliases out of the generated-code space (for example, require a character generated codes never use, such as-). - Expiry: check
expires_aton every read (return 410); a background job deletes expired rows in batches and evicts them from the cache. - Abuse: rate-limit creation per user and IP (Design D02), check new URLs against threat lists asynchronously, and be able to disable a code everywhere within a minute: mark it in the database, delete it from Redis, and purge it from the CDN.
9.6 Keeping printed links alive across regions always work · never lost
- Reads everywhere: serve redirects from every region, with a regional cache and a read replica (or a multi-region key-value table) in each. A region outage only moves traffic.
- Writes in one place, or blocks per region: creation is rare, so it can stay in one primary region with failover. Or each region leases its own counter blocks, so codes stay unique without cross-region coordination.
- Backups and the domain: point-in-time backups of the links table, tested restores, and the short domain itself registered with auto-renew and careful DNS. A lapsed domain breaks every link at once.
9.7 The whole design after the deep dives
Both paths through the final design: creating a link with a leased counter block, and redirecting through the edge, the cache and the database, with the click going to analytics.
10. Interviewer follow-ups
Traffic grows 10×: 1 billion links a month.
Writes reach ~400 a second, still modest. The links table grows to ~30 TB over five years, so partition it by hashing the code (or use a key-value store that does it for you). Counter blocks per region or per server keep generation local. Push more redirects to the edge; the origin load grows far less than 10× because popular links are served from caches.
One link goes viral: 1M clicks a minute.
That's one key. The CDN serves it from every edge with a short cache lifetime, and the redirect service keeps the hottest codes in process memory, so neither Redis nor the database notices. Analytics events arrive in a burst; Kafka absorbs it.
Owners now want to change a link's destination.
Add a version to the link. Edits bump it, and the redirect service caches by code with a short lifetime (or listens for invalidation events). CDN entries are purged on edit. It costs more cache misses, which is why it was "not now".
Should the same long URL always get the same code?
Not across users: it would merge their stats and reveal what others shortened. Per user, you can dedupe with an index on (owner_id, hash(long_url)) if the product wants it.
How would you count unique visitors?
Exact uniques need storing every visitor per link. A HyperLogLog per link per day estimates them with ~1% error in about 12 KB each, and the sketches can be merged across days (Design D20).
A link is reported as phishing. How fast can you stop it?
Mark it disabled, delete it from Redis, purge it from the CDN, and broadcast an invalidation to the redirect servers' in-process caches. All of this takes under a minute. That's why we chose 302: a 301 would stay cached in browsers.
Support custom domains (go.acme.com/offer).
Make the key (domain, code) instead of just the code. Map each customer domain to the service with DNS and issue TLS certificates automatically. The redirect path looks the domain up first. Alias uniqueness becomes per domain.
Move from PostgreSQL to a key-value store without downtime.
Dual-write new links to both, backfill old links in batches, compare reads in the background (shadow reads), then switch reads over, and finally stop the old writes. Redirects never stop, because the cache sits in front of both.
Why not generate codes from a timestamp?
Timestamps collide when two servers create links in the same millisecond, and they reveal when a link was made. A Snowflake-style id (time + machine + sequence) avoids collisions but is longer than 7 characters. Leased counter blocks are shorter.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Links database primary down | Can't create links; redirects keep working | Write errors; failover event | Redirects served by caches and replicas; a replica is promoted |
| Redis cluster lost | Slightly slower redirects | Cache-miss rate jumps | Database and replicas absorb misses; the cache refills by itself |
| Counter service unavailable | Creation slows after blocks run out | Block lease errors | Servers hold leased blocks; fall back to random codes with retry |
| Kafka slow or down | Stats a few minutes behind | Consumer lag | Redirect servers buffer events locally; nothing on the redirect path waits |
| A region goes down | Nothing, for redirects | Regional health checks | DNS or anycast moves traffic to other regions |
| Phishing campaign using our links | Blocked links show a warning page | Reports, threat-feed matches | Disable everywhere within a minute; creation rate limits |
12. Wrap-up and the clock
Trade-offs we made:
- Leased counter blocks instead of random codes (no collisions; the predictability is fixed by scrambling).
- 302 over 301 (analytics and blocking over traffic).
- Asynchronous analytics (fast redirects, counts a minute behind).
- Separate create and redirect services.
What we'd watch:
- Redirect latency p99 and error rate per region.
- Cache hit rate at the CDN and Redis.
- Creation rate per user (abuse).
- Analytics consumer lag.
At 10×:
- Partition the links table.
- Counter blocks per region.
- More edge serving.
A 45-minute interview
| 0–4 | Frame the problem (scale, aliases, expiry, analytics) |
| 4–9 | Must do, how well, numbers |
| 9–14 | Nouns and contract |
| 14–25 | First working version 8.1–8.3, parked list |
| 25–40 | Deep dives: codes (9.1), fast redirects (9.2), 301/302 and analytics (9.3–9.4) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–12 | Must do, how well, numbers |
| 12–17 | Nouns and contract |
| 17–28 | First working version |
| 28–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: viral link, custom domains, migration; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Clear framing, the two main APIs, a working design with a database and a cache, and a sensible code-generation scheme with collision handling. |
| Senior | Separate read and write paths; counter blocks or random codes defended with numbers; 301 vs 302; analytics off the hot path; hot links at the edge; a fast disable path. |
| Staff+ | Drives the trade-offs: storage choice and migration, multi-region redirects, abuse handling as a first-class requirement, operational concerns (cache warm-up, the domain itself), and what changes at 10×. |
Common mistakes:
- Hashing the URL and truncating it.
- Reading the database on every redirect.
- Counting clicks with a synchronous write.
- Choosing 301 without noticing it hides clicks.
- Forgetting abuse and expiry.
- Sharding a 3 TB table on day one.
13. Try it, check yourself, read more
How long do codes last?
Encode a counter value
The product sells click analytics. Which redirect status?
How do you keep a central counter from becoming a bottleneck?
Best way to count clicks?
The links database primary fails. What happens to redirects?
| Resource | Why |
|---|---|
| Design a URL shortener like Bit.ly (Hello Interview) | Another walkthrough with common follow-ups. |
| Design Pastebin and bit.ly (System Design Primer) | A worked solution with estimates and code generation. |
| Redirections in HTTP (MDN) | 301, 302, 307 and 308, and how browsers cache them. |