Design a URL Shortener (bit.ly)
Design a service that converts long URLs into short, unique aliases and redirects users who visit the alias to the original URL. The core challenges are generating collision-free short codes at scale, serving redirects with very low latency, and handling a read-heavy workload that can be two orders of magnitude larger than writes.
1Requirements
Functional
- • Given a long URL, generate a unique short URL (e.g., short.ly/aB3xZ91).
- • Redirect users who open a short URL to the original long URL.
- • Support optional custom aliases chosen by the user.
- • Support optional expiration times, after which the short URL stops working.
- • Provide basic click analytics (total clicks, clicks over time) per short URL.
Non-functional
- • High availability: redirects are on the critical path for other sites, so target 99.99% uptime.
- • Low latency: redirect lookups should complete in under 50 ms at p99.
- • Short codes must be unguessable enough to avoid trivial enumeration, and must never collide.
- • The system is read-heavy (roughly 100:1 read to write ratio) and must scale reads independently.
- • Durability: once created, a mapping must never be lost, since links are embedded in emails and documents forever.
2Back-of-envelope estimation
| Write QPS | 100M new URLs/month ≈ 40 writes/sec, peak 2x ≈ 80/sec | 100M / (30 x 86,400) ≈ 38.6 |
| Read QPS | 100:1 read ratio → 4,000 reads/sec, peak 2x ≈ 8,000/sec | Reads dominate; cache aggressively |
| Storage (5 years) | 100M/month x 60 months = 6B rows x 500 bytes ≈ 3 TB | Fits on a few sharded machines; storage is not the bottleneck |
| Short code space | Base62 with 7 chars = 62^7 ≈ 3.5 trillion codes | 6B needed over 5 years, so 7 characters is comfortable |
| Cache size | 20% of daily reads x unique URLs ≈ 70M hot entries x 500 B ≈ 35 GB | 80/20 rule: cache the hot 20% and serve most traffic from memory |
3API design
POST /api/urlsCreate a short URL. Body: { longUrl, customAlias?, expiresAt? }. Returns { shortUrl, shortCode }. Idempotency key header recommended to avoid duplicates on retry.
GET /{shortCode}Redirect endpoint. Looks up the long URL and returns HTTP 301 (permanent, cacheable) or 302 (temporary, lets you keep counting clicks). Returns 404 if unknown or expired.
DELETE /api/urls/{shortCode}Delete or deactivate a short URL owned by the authenticated user.
GET /api/urls/{shortCode}/statsReturn click analytics: total clicks, clicks by day, top referrers.
4High-level design
Clients hit a load balancer that fronts a fleet of stateless API servers. Because the servers hold no session state, we can scale them horizontally behind the balancer and any server can handle any request. Writes (create URL) and reads (redirect) can be served by the same fleet, or split into separate services so the huge read volume never starves writes.
On the write path, the API server obtains a unique ID and encodes it in Base62 to produce the short code. The cleanest approach is a Key Generation Service: an offline worker pre-generates batches of unique codes and stores them in a key database; API servers grab a batch of unused keys into memory and hand them out with zero collision risk and no coordination per request. The mapping (short_code → long_url, owner, expiry) is written to the primary datastore.
On the read path, the server first checks a distributed cache (Redis) keyed by short code. On a hit, it issues the redirect immediately. On a miss, it reads the datastore, populates the cache with a TTL, and redirects. With a 100:1 read ratio and a heavily skewed popularity distribution, cache hit rates above 90% are realistic, which keeps p99 latency low and shields the database.
The datastore itself can be a simple key-value store (DynamoDB, Cassandra) or sharded MySQL, since the access pattern is a single-key lookup with no joins. Shard by hash of the short code for even distribution. Click events are not written synchronously on the redirect path; instead the server emits an event to a message queue, and an analytics consumer aggregates counts in batches, keeping redirects fast.
5Data model
urls
id BIGINT PK, short_code VARCHAR(7) UNIQUE, long_url TEXT, user_id BIGINT, created_at TIMESTAMP, expires_at TIMESTAMP NULLIndex on short_code; this is the hot lookup path
users
id BIGINT PK, email VARCHAR(255) UNIQUE, api_key VARCHAR(64), created_at TIMESTAMPclick_events
event_id UUID PK, short_code VARCHAR(7), ts TIMESTAMP, referrer VARCHAR(255), country CHAR(2)Append-only; aggregated asynchronously into daily rollups
6Deep dives
Short code generation: hashing vs counter vs key service
Option 1 is hashing the long URL (MD5/SHA-256) and taking the first 7 Base62 characters. It is simple and deterministic, but truncation causes collisions that you must detect and resolve with retries, and the same URL from two users maps to one code, which breaks per-user analytics and expiry.
Option 2 is a global auto-incrementing counter encoded in Base62. It guarantees uniqueness with no collision checks, but a single counter is a single point of failure and a scaling bottleneck, and sequential codes are enumerable, letting attackers scrape every link. You can mitigate enumeration by multiplying by a large prime modulo the keyspace or applying a bijective scramble.
Option 3, the usual production answer, is a Key Generation Service: pre-generate random unique codes offline, store them partitioned into used and unused, and let each API server lease a block of a few thousand keys into memory. Handing out a key is a local in-memory operation, collisions are impossible by construction, and losing a server merely wastes its leased block, which is acceptable given 3.5 trillion possible codes.
301 vs 302 redirects and caching implications
HTTP 301 (Moved Permanently) tells browsers and intermediate proxies to cache the mapping, so repeat visits skip your servers entirely. That reduces load dramatically but has two costs: you lose visibility into repeat clicks, so analytics undercount, and you cannot quickly retarget or kill a link because clients keep using the cached destination.
HTTP 302/307 forces every click back through your service, giving accurate analytics and instant control over expiry and abuse takedowns, at the cost of higher traffic. Most commercial shorteners choose 302 because analytics is the product. A middle ground is 301 with a short Cache-Control max-age, which bounds staleness while still shedding some load.
Scaling reads: cache strategy and hot keys
Use cache-aside with Redis: read cache, on miss read DB and populate with a TTL of hours to a day. Popularity follows a power law, so a modest cache absorbs the vast majority of reads. Evict with LRU and size the cluster around the hot set estimate (tens of GB).
A single viral link can become a hot key that overwhelms one Redis shard. Mitigations: replicate the hot key across several cache nodes and randomize which replica a server reads, add a small in-process cache (a few thousand entries with a 1-5 second TTL) on each API server, and use request coalescing so concurrent misses for the same key trigger only one DB read.
Also protect against cache penetration: lookups for nonexistent codes always miss the cache and hit the DB. Cache negative results briefly, or keep a Bloom filter of all issued codes in front of the database so unknown codes are rejected in memory.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Next.js API routes + Postgres + Redis (Upstash free tier) on a $6 VPS or Vercel hobby plan behind Cloudflare
- 01Create a urls table: id BIGSERIAL PK, short_code VARCHAR(7) with a UNIQUE index, long_url TEXT, created_at, expires_at NULL.
- 02Write a base62 encoder that turns the auto-increment id into a short code, offset by a large constant (e.g. 100000000) so codes start at 5-6 chars.
- 03Build POST /api/urls: validate the URL with the URL constructor, insert the row, encode the returned id, update the row with the code, return short URL.
- 04Support custom aliases by inserting the alias directly and catching the Postgres 23505 unique-violation error to return 409.
- 05Build GET /[code]: look up Redis first, fall back to Postgres, SET the code in Redis with a 24h TTL, respond with a 302 redirect.
- 06Fire-and-forget an INCR on clicks:{code} in Redis on each redirect; flush counts to Postgres with a cron every minute.
- 07Add a nightly cron that deletes or deactivates rows where expires_at < now().
- 08Point Cloudflare at the app and enable caching of 404s to blunt enumeration scans.
Base62 encode from auto-increment id
typescriptconst ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const OFFSET = 100_000_000n; // avoid tiny 1-2 char codes
export function encodeBase62(id: bigint): string {
let n = id + OFFSET;
let out = "";
while (n > 0n) {
out = ALPHABET[Number(n % 62n)] + out;
n = n / 62n;
}
return out;
}
export function decodeBase62(code: string): bigint {
let n = 0n;
for (const ch of code) {
n = n * 62n + BigInt(ALPHABET.indexOf(ch));
}
return n - OFFSET;
}Create endpoint: insert, encode, retry on alias collision
typescriptasync function createShortUrl(longUrl: string, alias?: string) {
new URL(longUrl); // throws on invalid input
if (alias) {
try {
await sql(
"INSERT INTO urls (short_code, long_url) VALUES ($1, $2)",
[alias, longUrl]
);
return alias;
} catch (e: any) {
if (e.code === "23505") throw new Error("alias taken"); // 409
throw e;
}
}
// id-based codes cannot collide: insert first, derive code from id
const rows = await sql(
"INSERT INTO urls (short_code, long_url) VALUES ('pending', $1) RETURNING id",
[longUrl]
);
const code = encodeBase62(BigInt(rows[0].id));
await sql("UPDATE urls SET short_code = $1 WHERE id = $2", [code, rows[0].id]);
return code;
}Redirect handler with cache-aside Redis
typescriptexport async function GET(req: Request, ctx: { params: { code: string } }) {
const { code } = ctx.params;
let longUrl = await redis.get("url:" + code);
if (!longUrl) {
const rows = await sql(
"SELECT long_url FROM urls WHERE short_code = $1 AND (expires_at IS NULL OR expires_at > now())",
[code]
);
if (rows.length === 0) return new Response("Not found", { status: 404 });
longUrl = rows[0].long_url;
await redis.set("url:" + code, longUrl, { ex: 86400 });
}
redis.incr("clicks:" + code); // not awaited, off the hot path
return Response.redirect(longUrl, 302);
}Bottlenecks & failure modes
- ⚠A single SQL instance cannot hold 6B rows with 8K QPS of reads; shard by hash of short_code and scale the cache tier first.
- ⚠Hot keys from viral links can saturate one cache shard; replicate hot entries and add per-server local caches.
- ⚠A naive global counter for ID generation is a single point of failure; use a key generation service or range-leased counters (e.g., via ZooKeeper).
- ⚠Writing click analytics synchronously on the redirect path adds latency; buffer events through a queue and aggregate asynchronously.
- ⚠Malicious enumeration and spam links require rate limiting on creation and a safe-browsing check pipeline.
Key takeaways
- ▸Identify the read-to-write ratio early; a 100:1 read-heavy system is designed around its cache, not its database.
- ▸Pre-generating keys (Key Generation Service) turns a hard distributed-uniqueness problem into a trivial local one.
- ▸The 301 vs 302 choice is a product decision disguised as a technical one: caching efficiency vs analytics and control.
- ▸Back-of-envelope math (62^7 ≈ 3.5T codes vs 6B needed) justifies design choices concretely in interviews.
- ▸Keep the redirect path minimal: cache lookup plus redirect; push everything else (analytics, expiry cleanup) off the critical path.