Design a URL Shortener (Worked Example)
System Design & ArchitectureLesson 6 of 67 min read
What you'll learn
- Apply the 5-step framework to a complete design from requirements to scaling
- Estimate traffic, storage and short-code length with stated assumptions
- Compare ID generation approaches and implement correct base62 encoding
- Explain caching, scaling and the main trade-offs, such as 301 vs 302 redirects
On this page
A URL shortener turns a long link into a short one like sho.rt/aZ3k9Qx and redirects visitors back. It looks trivial, which is exactly why it is a favourite design question: it is small enough to finish and rich enough to touch every building block from the earlier lessons.
Step 1: Requirements
Start with scope. Here is a reasonable set for this exercise.
- Functional: create a short URL from a long URL; redirect a short URL to the original; optionally support custom aliases and expiry dates; (out of scope for now: user accounts, detailed analytics dashboards).
- Availability: redirects must be highly available. A broken short link breaks every page that shared it.
- Latency: redirects should feel instant, ideally tens of milliseconds at the server.
- Durability: a created link must not be lost.
- Correctness: no two long URLs may receive the same short code, and codes should be hard to guess in bulk.
- Scale: read-heavy, with many more redirects than creations.
Step 2: Back-of-envelope estimation
These numbers are illustrative assumptions for practice, not data about any real service.
| Quantity | Assumption / calculation | Result |
|---|---|---|
| New URLs | Assume 100M per month | 100M / 2.6M seconds is about 40 writes/s |
| Read to write ratio | Assume 100 : 1 | 10B redirects per month |
| Average redirects | 10B / 2.6M seconds | about 3,900 reads/s |
| Peak redirects | Assume 3x average | about 12,000 reads/s |
| Storage per record | Short code, long URL, timestamps, about 500 bytes | 500 B |
| Records in 5 years | 100M x 12 x 5 | 6 billion records |
| Total storage | 6B x 500 B | about 3 TB |
| Code length | 62^6 is about 56.8 billion; 62^7 is about 3.5 trillion | 7 characters gives ample headroom |
What the numbers tell us: writes are tiny (tens per second), reads are moderate (thousands per second, easily cached) and total storage of a few TB is modest. The system is read-dominated, so caching and replicas matter more than clever write scaling. A 7-character base62 code space is far larger than the 6 billion we need, which leaves room to avoid collisions.
Step 3: API and data model
POST /api/v1/urls
body: { "longUrl": "https://example.com/a/b",
"alias": "my-link", (optional)
"expiresAt": "2027-01-01" (optional) }
returns: 201 { "shortUrl": "https://sho.rt/aZ3k9Qx" }
errors: 400 invalid URL, 409 alias already taken
GET /{code}
returns: 301 or 302 with Location: <longUrl>
404 if unknown, 410 if expired| Column | Type | Notes |
|---|---|---|
| code | VARCHAR(10), primary key | The short code; the lookup key for every redirect |
| long_url | TEXT | Original URL |
| created_at | TIMESTAMP | For auditing and cleanup |
| expires_at | TIMESTAMP, nullable | Optional expiry |
| user_id | BIGINT, nullable | Owner, if accounts exist |
The access pattern is a single key lookup by code, with no joins. That fits a key-value or wide-column store well, but a relational database with a primary-key index also handles it comfortably at this scale, and gives you easy uniqueness guarantees. Either is defensible; state the reason for your pick.
301 or 302?
A 301 (permanent) redirect may be cached by browsers, which reduces your load but means later clicks never reach your server, so you cannot count them or change the target. A 302 (temporary) redirect is not cached by default, so every click hits you. That costs more traffic but enables click analytics, expiry and editing. Pick based on whether analytics and mutability matter.
Step 4: Generating short codes
This is the heart of the problem. There are several approaches, each with trade-offs.
| Approach | How | Pros | Cons |
|---|---|---|---|
| Hash and truncate | Hash the long URL (e.g. MD5 or SHA-256), take the first 7 base62 characters | Same URL maps to same code; no counter needed | Collisions: must check and retry; extra DB lookup on write |
| Random code + check | Generate a random 7-character code, insert, retry on conflict | Simple, codes are unguessable | Collision probability rises as the table fills; needs unique constraint |
| Global counter + base62 | Increment a counter, encode the number in base62 | No collisions; short codes at first | Central counter is a bottleneck and SPOF; sequential codes are guessable |
| Counter ranges per server | Each app server leases a block of IDs (e.g. 1 million) from a coordinator and encodes locally | No per-request coordination; scales well | Gaps if a server dies; still sequential unless scrambled |
| Pre-generated key service | A separate service creates unique keys ahead of time and hands them out | Fast and collision-free at request time | Another component to keep reliable |
A pragmatic choice here is counter ranges plus base62. A small coordination service (or a database sequence) hands each app server a block of numbers; the server increments locally and converts each number to a base62 string. Writes need no cross-server locking, and uniqueness is guaranteed by construction. If guessability matters, scramble the number before encoding (for example, multiply by a large number coprime to the code space, modulo the space size) or mix in random bits. Here is a correct base62 encoder and decoder in TypeScript.
const ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// Requires a target of ES2020 or later for BigInt literals.
export function encodeBase62(n: bigint): string {
if (n < 0n) throw new RangeError("n must be non-negative");
if (n === 0n) return ALPHABET[0];
let out = "";
while (n > 0n) {
out = ALPHABET[Number(n % 62n)] + out; // least significant digit first
n = n / 62n;
}
return out;
}
export function decodeBase62(s: string): bigint {
let n = 0n;
for (const ch of s) {
const digit = ALPHABET.indexOf(ch);
if (digit === -1) throw new Error("invalid character: " + ch);
n = n * 62n + BigInt(digit);
}
return n;
}
// Optionally pad to a minimum length so all codes look alike
export function toShortCode(id: bigint, minLength = 7): string {
return encodeBase62(id).padStart(minLength, ALPHABET[0]);
}Output
encodeBase62(0n) -> "0"
encodeBase62(61n) -> "z"
encodeBase62(62n) -> "10"
encodeBase62(125n) -> "21"
encodeBase62(1000000000n) -> "15ftgG"
decodeBase62("15ftgG") -> 1000000000n
toShortCode(125n) -> "0000021"Check one value by hand: 125 = 2 x 62 + 1, so the digits are 2 and 1, which map to "2" and "1", giving "21". Padding with leading zeros keeps codes a uniform length without changing the decoded number.
Step 5: Architecture, caching and scaling
Clients
|
v
+-------------+
| CDN / | (optional edge caching)
| Load balancer|
+-------------+
| |
v v
+-------+ +-------+
| App 1 | | App 2 | stateless
+-------+ +-------+
| | \
| v v
| +-------+ +-------------+
| | Cache | | ID / range |
| | Redis | | coordinator |
| +-------+ +-------------+
v (miss)
+------------------+ +-------+
| Database (shards | | Queue |--> analytics
| by hash(code)) | +-------+
+------------------+- Redirect path (hot): load balancer, app server, cache lookup by code, database on a miss, then fill the cache and return the redirect. Use cache-aside with an LRU policy and a TTL.
- Cache sizing (illustrative): if the hottest 100M links at 500 bytes each are cached, that is about 50 GB, which fits across a few cache nodes. Real link traffic is heavily skewed, so a modest cache can absorb most reads.
- Stateless app tier: scale out behind the load balancer, as in Scalability and Load Balancing.
- Database: reads dominate, so add read replicas. At around 3 TB with a few thousand reads per second, a single well-indexed primary plus replicas may suffice at first; shard by
hash(code)when size or write rate demands it, as discussed in the databases lesson. - Analytics off the hot path: publish a click event to a queue and let workers aggregate it, so redirects never wait for analytics writes.
- Expiry: check
expires_aton read (return 410) and delete expired rows in a background job.
Trade-offs and failure modes
- Consistency: a newly created link should be readable immediately. If reads come from a lagging replica, a fresh link can briefly 404. Read from the leader for the first moments, or write through to the cache on creation. Per the CAP lesson, this is a read-your-writes need, while redirects of old links can tolerate staleness.
- Custom aliases: they can collide with generated codes. Reserve a separate namespace, or check uniqueness on insert and return 409.
- Abuse: short links hide destinations, so add rate limiting, malicious-URL checks and a way to disable a link.
- Coordinator failure: servers hold leased ID ranges, so a brief coordinator outage does not stop writes. A crashed server wastes the rest of its range, which is acceptable given the huge code space.
- Deleting and reusing codes: avoid reusing codes. A reused code can send people who saved the old link to the wrong place.
Common mistakes
- Starting with the database and hashing before estimating. Symptom: you cannot say how long the code must be, or whether a cache is needed at all.
- Using an auto-increment ID directly. Symptom: codes are guessable and the single database becomes the ID bottleneck once you scale out.
- Truncating a hash without collision handling. Symptom: two different URLs share one code, and users are redirected to someone else's page.
- Ignoring the read-heavy shape. Putting effort into write scaling while redirects hit the database every time.
- Writing analytics in the redirect request. Symptom: redirect latency rises and outages in analytics break the core feature.
How this shows up in interviews and real projects
This question tests structure more than cleverness: do you clarify scope, estimate, justify the ID strategy and talk about caching and failure? Practise saying each step aloud with timing, and use the system design interview prep guide as a checklist. For a larger loop, see how to crack FAANG interviews. The same shape (unique IDs, a read-heavy cache, a partitioned store) reappears in pastebins, file sharing, invite codes and ticketing systems.
Try it yourself
Extend the design with click analytics: count clicks per short link per day and show them to the link owner. Describe how the click event reaches the analytics store, what you would store, and what happens to redirects if the analytics system is down. Also write a function that converts a numeric ID into a short code of at least 6 characters using the base62 encoder above.
Show a hint
Publish a click event to a queue from the app server (fire and forget), and let workers aggregate counts by (code, day). Redirects must not depend on that path. For the code, call encodeBase62 and padStart to length 6.
Key takeaways
- Requirements and estimation show that a URL shortener is read-heavy with small writes, so caching and replicas matter most.
- A 7-character base62 code gives about 3.5 trillion combinations, plenty for billions of links.
- Counter ranges plus base62 give collision-free codes without a per-request bottleneck; scramble or randomise if codes must be hard to guess.
- Use 302 redirects if you need analytics or edits, and 301 when you prefer browser caching and lower load.
- Keep analytics off the redirect path and design for failure of the cache, coordinator and replicas.
Test yourself
A trick from this lesson's ride: Compare reads to writes before choosing what to scale. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
How does a URL shortener generate unique short codes?
Common approaches are hashing the long URL and truncating it, generating random codes with a uniqueness check, or using an incrementing counter encoded in base62. Large systems often lease ranges of counter values to each server so codes are unique without coordinating on every request.
How many characters does a short URL need?
It depends on how many links you must support. With base62 (digits plus upper and lower case letters), 6 characters give about 56.8 billion combinations and 7 give about 3.5 trillion, so 7 characters is comfortable for billions of links with room to spare.
Should a URL shortener use a 301 or 302 redirect?
A 301 is permanent and browsers may cache it, which lowers server load but hides repeat clicks and makes changes harder. A 302 is temporary and not cached by default, so every click reaches your servers, which enables analytics, expiry and editing at the cost of more traffic.