Design a URL shortener — walk through the full architecture
A URL shortener looks trivial until you have to generate a billion unique codes without a single counter becoming your bottleneck.
LEARN BY DOINGPut your intuition to work3 steps
Design a URL shortener — walk through the full architecture
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarify the requirements first
Before drawing any boxes, pin down what the system actually needs to do, because the right architecture for a URL shortener changes a lot depending on the numbers. The functional requirements are usually: given a long URL, return a short one; given a short URL, redirect to the original; optionally, let a user pick a custom alias; optionally, let a link expire. The non-functional requirements are where the interesting design decisions live: redirects must be fast (well under 100ms) and highly available, short codes must be unique, and the read traffic will vastly outweigh the write traffic — a link gets created once and clicked many times, so a realistic read:write ratio is somewhere from 10:1 to 100:1 or higher.
A rough capacity estimate grounds the rest of the design: say 100 million new links created per day (~1,160 writes/sec on average, several times that at peak) and a 100:1 read:write ratio, which puts sustained redirect traffic well into the tens of thousands of requests per second. At an average of ~500 bytes per record including metadata, five years of link creation lands around 90 terabytes — nothing that a single machine holds comfortably, and nothing that needs anything more exotic than a horizontally-scaled key-value store with a cache in front of it.
#Generating unique short codes
The core problem is turning a long URL into a short, unique identifier, and there are three approaches worth knowing, because interviewers usually want you to compare them rather than pick one blindly.
Counter + base62 encoding. Maintain a globally unique, monotonically increasing integer ID, then encode it in base62 (`[0-9a-zA-Z]`, 62 characters) to get a short alphanumeric string. Base62 is the practical choice over base64 because it avoids URL-unsafe characters (`+`, `/`) entirely, so you never need percent-encoding in the resulting link. With 7 base62 characters you get 62^7 ≈ 3.5 trillion possible codes — comfortably enough headroom. The catch is generating that integer ID without a single database counter becoming a write bottleneck or a single point of failure. The standard fix — sometimes called a "ticket server" pattern — has each application server claim a range of IDs at once (say, 1,000 at a time) from a small counter table with a single atomic update, then hand out IDs from that range locally without touching the database again until the range is exhausted. This turns millions of ID requests into a much smaller number of range-allocation round trips.
Hash of the URL. Take a hash (MD5 or SHA-256) of the long URL, base62-encode the first several characters of it. This is deterministic — the same URL always maps to the same code, which can be a feature (natural deduplication) or a bug (you can't let two different users get two different short links to the same URL, e.g., for separate analytics). It also needs collision handling: truncating a hash to 6-8 characters makes collisions possible, so you still need a uniqueness check against the database and a fallback (append characters, or use a different seed) when one occurs.
Pre-generated key pool. A background service continuously generates random unique base62 strings and stores the unused ones in a fast key-value store. When a write request comes in, the API server simply pops an already-verified-unique key off the pool — no counter contention, no hash-collision handling, and generation happens off the request's critical path entirely. The trade-off is operational: you now run and monitor a key-generation service and must guard the pool against two servers claiming the same key concurrently (typically solved with an atomic pop, like Redis's `SPOP`).
For most designs, counter-based range allocation is the answer worth defending because it's the simplest thing that scales, but naming the hash-based and pre-generated-pool alternatives — and their specific failure modes — is what separates a memorized answer from an understood one.
#The write path (create a short URL)
The API server validates the submitted URL (a real, syntactically valid URL, not something pointing at internal infrastructure — this endpoint is a classic SSRF target if you ever fetch the URL server-side to validate it), resolves an ID (from its locally-claimed range, or by checking a requested custom alias for uniqueness), encodes it, and writes the `{short_code, long_url, owner_id, created_at, expires_at}` record to the primary data store. A write-through to the cache at creation time is optional but pays for itself immediately if new links tend to be clicked soon after creation (they usually are — think of a link shared on social media, which gets most of its clicks in the first hours).
#The read path (redirect)
Because redirect traffic dominates, the read path is where the design earns its latency budget. A cache in front of the primary store (Redis or Memcached, sharded by `short_code`) absorbs the overwhelming majority of lookups, since click traffic on shortened links follows a heavily skewed distribution — a small fraction of links account for most of the clicks (a Zipf-like curve), so even a modest cache with a sensible eviction policy (LRU) captures most of the hot set. On a cache miss, the API server falls back to the primary store, serves the redirect, and populates the cache for next time.
Two design decisions worth calling out explicitly because interviewers listen for them:
- 302 vs. 301. A 301 (permanent redirect) tells the client's browser to cache the redirect target itself, so the browser will never come back to your server for that link again — great for reducing server load, terrible for your ability to change the destination later, track click analytics, or expire the link. A 302 (temporary redirect, or 307 if you need the method preserved) keeps every click flowing through your service, which is almost always what you actually want for a URL shortener.
- Click tracking must not be synchronous with the redirect. Writing an analytics row to a database on every single redirect would put a write on your hottest, latency-critical path. Instead, fire the click event at a message queue (Kafka, or even a lightweight in-memory buffer flushed periodically) and let a separate consumer aggregate it — the user gets their redirect immediately, and analytics catches up asynchronously.
#Data store and sharding
The access pattern here is almost embarrassingly simple: a point lookup by primary key (`short_code`) with no joins and no complex queries, which is exactly the shape a key-value store or a wide-column store (DynamoDB, Cassandra) is built for, and it shards cleanly by hashing `short_code`. A relational database works too at moderate scale — it's just a table with an index on `short_code` — and plenty of real systems run this on Postgres far longer than people expect before sharding becomes necessary; the earlier lesson on vertical vs. horizontal scaling applies directly here.
#A real base62 implementation
BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"BASE = 62def encode_base62(num: int) -> str:if num == 0:return BASE62_ALPHABET[0]digits = []while num > 0:num, remainder = divmod(num, BASE)digits.append(BASE62_ALPHABET[remainder])return "".join(reversed(digits))def decode_base62(short_code: str) -> int:num = 0for char in short_code:num = num * BASE + BASE62_ALPHABET.index(char)return num# encode_base62(125_000_000) -> "8M0T2"# decode_base62("8M0T2") -> 125000000
class IdRangeAllocator:"""Hands each application server a contiguous block of unique integer IDsup front, so encoding a new short link almost never requires a databaseround trip — only claiming a fresh range does, roughly once perrange_size requests instead of once per request."""def __init__(self, db, range_size: int = 1000):self.db = dbself.range_size = range_sizedef allocate_range(self):row = self.db.execute("""UPDATE id_counterSET current_max = current_max + %sWHERE name = 'url_shortener'RETURNING current_max - %s AS range_start, current_max AS range_end""",(self.range_size, self.range_size),).fetchone()return row["range_start"], row["range_end"]
🧠 Check your understanding
0/2 · 0/2 answered1. Why is a 302 redirect generally preferred over a 301 for a URL shortener's redirect endpoint?
2. What specific problem does range-based ID allocation (the 'ticket server' pattern) solve compared to a single shared auto-increment counter?