Designing a URL Shortener: A System Design Walkthrough
I walk through designing a URL shortener from requirements to caching, covering ID generation, the 301-vs-302 redirect decision, and scaling the read path.

A URL shortener looks like the simplest possible data service: store a short code, look up the long URL, redirect. A hash map would handle it fine for a hundred users.
Then you think about analytics. To count clicks, every redirect must pass through your server. The obvious choice for a permanent redirect — HTTP 301 — tells browsers to cache the destination and skip your server on every future click. Your click counts stay at zero from that point forward.
That single decision about redirect type shapes everything else: which storage layer to pick, where to put the cache, how much server load to expect. A URL shortener is a read-heavy lookup service with one deceptive wrinkle at the protocol layer. I’ll walk through the full design here, from requirements to a scaled read path.
Series: Part 2 of 4. Part 1: System Design Basics — Scaling from One User to One Million
On this page
- What you’re actually designing
- Generating short codes without collisions
- The redirect decision that breaks analytics
- Choosing a storage layer for a read-heavy service
- Caching the hot path
- What breaks first in a real deployment
- The design is really about the lookup
What you’re actually designing
The service has two endpoints. A write endpoint accepts a long URL and returns a short code. A read endpoint receives a short code and redirects to the corresponding long URL. That’s it.
The traffic shape is heavily skewed toward reads. A popular link might be created once and clicked thousands of times. System design interviews typically model this at a 100:1 read-to-write ratio, and real services skew even higher. This means the read path — code lookup and redirect — is where almost all the architectural work lives. The write path creates a record and moves on.
Three things have to work well:
- Short code uniqueness: two different URLs must never produce the same code.
- Fast redirect latency: the lookup should be fast enough that users don’t notice the extra hop.
- Analytics capture: every redirect needs to register a click without making the redirect itself slower.
The short code space gives you more room than you’d expect. With base-62 encoding — digits 0–9, uppercase A–Z, lowercase a–z — a seven-character code can represent 62⁷ ≈ 3.5 trillion distinct values. At a rate of one thousand new links per second, that capacity lasts about a century without recycling codes.
Generating short codes without collisions
Two approaches come up in almost every design discussion: hashing the input URL, or using a counter.
Hashing takes the long URL, runs it through a function like MD5 or SHA-256, and takes the first seven base-62 characters of the result. The appeal is that the same URL always produces the same code, which guarantees 1-to-1 mapping without a database lookup. The problem is collisions: two different long URLs can produce identical seven-character prefixes. At scale this stops being theoretical. You’d need to detect the collision on every write and retry with an adjusted input, which adds a database round trip to the write path.
A counter avoids the collision problem entirely. Assign each new URL an auto-incrementing integer and base-62 encode it. Each integer is unique by definition, so two different URLs always get two different codes.
A simple encode function looks like this:
const CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
/** Encodes a positive integer as a zero-padded 7-character base-62 string. */
function toBase62(n: number): string {
if (n === 0) return CHARS[0].repeat(7);
let result = '';
let remaining = n;
while (remaining > 0) {
result = CHARS[remaining % 62] + result;
remaining = Math.floor(remaining / 62);
}
return result.padStart(7, '0');
}
// toBase62(1) → "0000001"
// toBase62(62) → "0000010"
// toBase62(3_521_614_606_208) → "zzzzzzz"
The trade-off with a counter is predictability. The sequence is monotonically increasing, so anyone incrementing a known code by one gets a valid link. For a public service this is a privacy concern — not a correctness problem, but a real one. Two mitigations work in practice: start the counter at a large random offset so the first issued code isn’t “0000001”, or shuffle the base-62 character order consistently so the output looks non-sequential.
In a distributed system, a single counter becomes a bottleneck. One solution is a token range service: each application instance claims a non-overlapping range of integers atomically (for example, instance A owns 1–1,000,000 and instance B owns 1,000,001–2,000,000). Each instance hands out IDs from its local range without coordination until the range is exhausted, then claims a new one.
The redirect decision that breaks analytics
When a user visits a short link, your server needs to respond with the long URL and an HTTP status code that tells the browser what to do next.
Before you continue: if you respond with 301, what happens on the user’s second visit to the same short link?
The answer: the browser never contacts your server. A 301 Moved Permanently response instructs the browser to cache the redirect destination indefinitely. Every subsequent click skips your server entirely. This is fine for performance, but your analytics service sees exactly one click per browser — the first one.
A 302 Found response has no built-in client-side caching. Every visit hits your server, which means every visit is recorded. The downside is marginally more server load for repeat visitors, but that’s why the cache layer exists.
The choice depends on your requirements:
| Requirement | Redirect type | Reason |
|---|---|---|
| Track every click for analytics | 302 | No browser-side caching |
| Minimise server load, analytics not needed | 301 | Browser caches and skips server |
| Pass POST body through on redirect | 307 | Preserves HTTP method and body |
Most URL shortening services use 302 or 307 for exactly this reason. The 301 response is the semantically correct one — the mapping IS permanent — but analytics matter more than semantic purity in most deployments.
There’s a second reason to prefer 302: error recovery. Once a browser caches a 301, the only way to correct a wrong redirect is to allocate an entirely new short code. The cached entry is permanent on the client side without a cache reset. With 302, you can fix the destination at any time and the next request picks up the change.
Check this before moving on
- You know which HTTP status code your service returns on redirect and why
- You understand that 301 caching prevents analytics from recording repeat visits
- You’ve decided whether you need click analytics before choosing the redirect type
Choosing a storage layer for a read-heavy service
The core access pattern for the URL table is: given a short code, return the long URL. This is a point lookup — no joins, no aggregations, no relational constraints between URL records.
A relational database handles this access pattern without trouble at small scale. But the access pattern is not what relational databases are optimised for. Adding an index on the short code column makes lookups fast, but you’re still running a single-column query against a potentially massive table, with overhead from the relational engine you’re not using. If you do go with a relational store for the URL table, understanding how PostgreSQL handles indexing and query plans will matter earlier than you’d expect.
A key-value store is a more natural fit. Short codes are the keys; long URLs are the values. The lookup is O(1), horizontal scaling is straightforward, and you’re not paying the relational overhead. This is the URL table only — user accounts, permissions, and analytics aggregates belong in relational storage where complex queries and ACID guarantees are actually needed.
| Data type | Recommended store | Reason |
|---|---|---|
| short code → long URL | Key-value store | Point lookups, no joins, easy to shard |
| User accounts and permissions | Relational database | Complex queries, referential integrity |
| Click events for analytics | Append-only log or event queue | High-volume writes, time-series access |
The URL table doesn’t need transactions — two concurrent writes can create two separate codes for the same URL without corrupting data. Custom aliases (user-chosen codes) are the exception: two users requesting the same alias simultaneously need a write-time conflict check, which you handle with a unique constraint and application-level retry.
Caching the hot path
With a 100:1 read-to-write ratio, the URL table gets read far more than it gets written. And popular links are accessed far more than obscure ones — a link that goes viral can generate thousands of requests per minute to a single record.
The read path without caching looks like: receive request → parse short code → query database → redirect. Every redirect hits the database.
With caching, it looks like: receive request → parse short code → check cache → if hit, redirect immediately; if miss, query database, store in cache, redirect. The database only handles cold cache misses. An in-memory store — Redis is the standard choice here — typically answers lookups in under a millisecond, which is the whole point.
The mapping between a short code and its destination rarely changes after creation, which makes it an ideal candidate for a long TTL. A 24-hour TTL is a reasonable default — it keeps popular links in memory without worrying about stale data. If you need to support redirect updates (changing the destination of an existing short code), you invalidate the cache key explicitly on write. The broader cache invalidation patterns and CDN-level strategies are covered in the API Caching Strategies post if you want to go further on that side.
LRU (Least Recently Used) eviction works well here. Most links receive burst traffic early — when the link is first shared — and then go quiet. An LRU policy naturally evicts cold links to make room for newly popular ones.
One complication: single-use links. If a link is generated and clicked exactly once, caching it wastes memory. A Bloom filter in front of the cache can help here: the filter tracks which short codes have been accessed more than once, and the cache is only populated after a second access. This prevents one-shot links from polluting the cache with data that’ll never be reused.

The core read path: client hits the service, cache intercepts the hot lookup, and the database handles misses. The key-value store maps each short code to its destination. The counter strip represents base-62 ID generation.
What breaks first in a real deployment
Hot URL problem. A single viral link saturates one cache shard. If you’re using consistent hashing, all traffic for that short code routes to one instance. The fix is replication at the cache layer: designate leader-follower replicas and distribute read traffic across followers. Leader handles invalidations; followers handle reads.
Custom alias conflicts. Two users request the same custom alias at the same time. Both check the database, both find it empty, both try to insert. One succeeds and one gets a conflict error. Handle this with a unique constraint on the short code column and retry logic in the application layer — the second request simply receives a “code already taken” error.
Sequential code enumeration. Counter-based IDs are predictable. An attacker incrementing a known code by one gets a valid link. This is a privacy concern documented in security research: shortened links to private shared documents, cloud storage, or calendar invites can be discovered by brute-forcing a short code space. If your service shortens links to private content, randomise the counter offset or apply a reversible scrambling function to the ID before encoding.
Stale 301 redirects. If you use 301 responses and later need to correct a redirect destination, you can’t do it for users whose browsers already cached the old response. The only fix is allocating a new short code and pointing users at it. This is recoverable, but it breaks any links already distributed with the original code.
The design is really about the lookup
Here’s the shift: the interesting part of a URL shortener isn’t the shortening. Creating a link is a one-time write. The hard part is the lookup — serving that one record reliably, at low latency, billions of times.
Every architectural choice in this design follows from that: the counter over the hash function (no collisions, no retry), 302 over 301 (every hit is visible), key-value store over relational storage (point lookup without relational overhead), in-memory cache with LRU eviction (fast hot path, automatic cold-link eviction).
If you’re working through the design as an exercise, start with the redirect type. That decision is the least obvious and it constrains everything downstream — your analytics architecture, your cache invalidation strategy, and what happens when you need to correct a redirect.
Sources
- URL shortening — Wikipedia
- Base62 — Wikipedia
- URL Shortening System Design — systemdesign.one
- Redirections in HTTP — MDN Web Docs
- HTTP response status codes — MDN Web Docs
- Gone in Six Characters: Short URLs Considered Harmful for Cloud Services — Cornell arXiv
- Bitly: Lessons Learned Building a Distributed System That Handles 6 Billion Clicks a Month — High Scalability