Build a service like bit.ly: take a long URL, return a short alias, redirect on click. The classic system design interview problem.
We use real production components: nginx, Node.js, Redis, PostgreSQL, Kafka, and ClickHouse.
URL shorteners are everywhere. Most major platforms run one in-house, both as a product and as internal plumbing for analytics, sharing, and tracking.
| Service | Owner | Purpose |
|---|---|---|
t.co | Twitter / X | Every link posted on Twitter is wrapped in t.co for click tracking and abuse filtering. |
bit.ly | Bitly Inc. | The flagship commercial shortener. ~12B clicks/month. Powers branded short links for thousands of businesses. |
youtu.be | YouTube | Short alias for youtube.com/watch?v=…. Used in shares, embeds, mobile apps. |
amzn.to | Amazon | Affiliate-friendly product links. Hides long ASIN URLs in influencer posts. |
wa.me | WhatsApp / Meta | Direct-message a phone number (e.g. wa.me/15551234). Powers "Click to chat" buttons everywhere. |
lnkd.in | Profile and post links in InMail, ads, mobile sharing. | |
fb.me | Meta | Facebook's short URL for posts, pages, events. Heavily used in SMS share flows. |
git.io | GitHub (retired 2022) | Short links for GitHub URLs. Shut down because of abuse — exactly the failure mode we discuss in Reliability. |
goo.gl | Google (sunset 2018) | Standalone shortener. Discontinued: hard to monetize on its own, Google rolled the feature into Firebase Dynamic Links. |
tinyurl.com | Independent | The OG, launched 2002. Still alive, still serving. |
| Use case | Why a short URL matters |
|---|---|
| Social media posts | Twitter's 280-char limit, Instagram bio (1 link), TikTok captions. Every character saved is room for commentary. |
| SMS marketing | 160-char SMS. A long URL eats half the message. Short URL = more pitch room + better CTR. |
| QR codes | Shorter URL → smaller QR code with lower density → scans reliably from further away, prints smaller on a poster. |
| Print advertising | People type URLs from magazines, billboards, business cards. Nobody types https://store.example.com/spring-sale-2026?utm_source=billboard&utm_id=42. |
| Email marketing | Cleaner CTAs, no long URL line-wrapping, easy to swap targets without changing the link. |
| UTM tracking hidden | 50+ char UTM parameters tucked behind a short URL. Marketers get the analytics; users see a clean link. |
| Affiliate links | Influencer codes (amzn.to/3xY9). Referral attribution survives copy-paste. |
| Branded short links | Companies pay for your-brand.co/promo instead of bit.ly/3xy9. The short domain is a brand asset. |
| Internal go-links | Engineering teams run private shorteners: go/oncall, go/runbook. Famously used at Google, Facebook, Stripe. Same architecture, internal-only. |
| App deep links | One short URL routes to the iOS app, the Android app, or a web fallback. Firebase Dynamic Links, Branch.io, Adjust. |
git.io was shut down precisely because abuse exceeded what was sustainable.Three flows below: create a short URL, redirect on cache hit (~2 ms), redirect on cache miss (~11 ms). Hover any component for details. Use the fullscreen button to expand the diagram.
sho.rt/my-blog).Size first, design second. These numbers drive every later decision.
| Metric | Calculation | Result |
|---|---|---|
| Total URLs over 5 years | 200M × 12 × 5 | 12 billion entries |
| Storage | 12B × 500 bytes | ~6 TB |
| Writes / sec | 200M ÷ 2,628,288 sec/month | 76 writes/s |
| Reads / sec | 76 × 100 (read ratio) | 7,600 redirects/s |
| Incoming bandwidth (writes) | 76 × 500B × 8 | 304 Kbps |
| Outgoing bandwidth (reads) | 7.6K × 500B × 8 | 30.4 Mbps |
| Cache memory (80/20 rule) | 20% × 7.6K × 86,400 × 500B | ~66 GB |
| App servers (peak DAU as proxy) | 100M req/s ÷ 64K RPS per server | ~1,600 servers |
| Short code space | 58⁷ (7-char Base58) | 2.2 trillion codes |
Three REST endpoints expose the service. Every endpoint requires an api_dev_key for rate-limit accounting and abuse tracking.
POST /shorten
{
"api_dev_key": "...", // user identifier (required)
"original_url": "https://...", // long URL to shorten (required)
"custom_alias": "my-blog", // optional user-chosen code
"expiry_date": "2031-05-14" // optional, default 5 years
}
→ 201 Created
{ "short_url": "https://sho.rt/2JjVS" }GET /:url_key?api_dev_key=... → 302 Found Location: https://example.com/article/long-path
DELETE /:url_key
{ "api_dev_key": "..." } // must match owner
→ 200 OK
{ "message": "URL removed" }custom_alias: the API replaces the long URL behind the existing short code (subject to ownership check).nginx is the entry point. Every request hits it first. Three jobs:
least_conn.Three swim lanes below, one per client IP. Each IP has its own independent token bucket. Click Burst 9 on one row to flood that IP. Only that bucket drains. The other two stay full. That is the per-IP isolation nginx gives you.
Each client IP gets its own bucket of 5 tokens. Refill 1 token every 1500ms. Burst one IP to watch only that bucket empty while others stay full.
429 Too Many Requests. Other IPs are unaffected. Allowed requests are forwarded to the least-loaded API replica.http {
# Token bucket: 5 req/s per IP, burst up to 10
limit_req_zone $binary_remote_addr zone=urlapi:10m rate=5r/s;
upstream api {
least_conn;
server api-1.svc:3000 max_fails=2 fail_timeout=10s;
server api-2.svc:3000 max_fails=2 fail_timeout=10s;
server api-3.svc:3000 max_fails=2 fail_timeout=10s;
keepalive 64;
}
server {
listen 443 ssl http2;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
location / {
limit_req zone=urlapi burst=10 nodelay;
limit_req_status 429;
proxy_pass http://api;
}
}
}The API is stateless. Any replica can serve any request. The interesting bit is how it generates short codes. Three approaches:
| Approach | Verdict | Why |
|---|---|---|
| MD5 hash truncation | ✗ | Collisions need retry logic. |
| Random Base58 string | ✗ | Expensive UNIQUE check on every insert. |
| Counter + Base58 | ✓ | Atomic counter, collision-free by construction. |
id = redis.INCR("url:counter") // atomic, globally unique
code = base58_encode(id) // collision-freeBase58 uses 58 URL-safe characters: 1-9, A-Z without I/O, a-z without l. A 64-bit ID fits in at most 11 Base58 characters. 58⁷ ≈ 2.2 trillion 7-char codes, far more than we need. Step through the encoding below:
counter ID → short code · 58 URL-safe chars · no 0 O I l + / confusion
0 O I l + / so a hand-typed short URL is never misread.+ and / which break URL parsing. Base62 still has visually ambiguous characters: 0 vs O, I vs l vs 1. Base58 drops 0, O, I, l, +, / so a short URL is never misread when someone types it from a printed flyer or reads it aloud.A 64-bit sequencer can produce numbers from 1 to 2⁶⁴ − 1. We need to constrain that range so every code is between 6 and 11 characters:
log₂(2⁶⁴) / log₂(58) ≈ 10.9 Base58 digits. Round up to 11. The longest possible short URL is 11 chars.log₂(10) ≈ 3.32 bits per digit. Base-58 packs log₂(58) ≈ 5.85 bits per digit. So a 64-bit ID takes ~20 decimal digits but only ~11 Base58 chars. That is the readability win, made tangible.A user can ask for sho.rt/my-blog instead of an auto-generated alias. The flow:
The sequencer generates IDs into an "unused" pool. Once an ID is allocated (either by the sequencer or by a custom alias claim), it moves to the "used" pool. This guarantees a one-to-one mapping between numeric IDs and short URLs and prevents collisions.
// On custom-alias request:
const id = base58_decode("my-blog"); // e.g. 9,181,722,813
const exists = await pg.query(
"SELECT 1 FROM urls WHERE id = $1 OR short_code = $2",
[id, "my-blog"]
);
if (exists) return { error: "alias unavailable" };
await pg.query(
"INSERT INTO urls (id, short_code, long_url, user_id) VALUES ($1, $2, $3, $4)",
[id, "my-blog", longUrl, userId]
);
// id is now in the "used" pool. Sequencer skips it on next INCR.Decoding the alias back into a numeric ID is straightforward positional notation: each char's index × 58^position, summed.
// "2JjVS" → 14,776,337 // // 2 → idx 1 × 58⁴ = 11,316,496 // J → idx 17 × 58³ = 3,316,904 // j → idx 42 × 58² = 141,288 // V → idx 28 × 58¹ = 1,624 // S → idx 25 × 58⁰ = 25 // ───────────────────────────────── // sum = 14,776,337 ✓
Deletion is a soft remove first (so click analytics aren't orphaned), hard delete after a grace window. Expired short URLs are purged after 5 years even if never accessed: the datastore index would otherwise grow without bound, blowing up query latency.
-- Daily cleanup job DELETE FROM urls WHERE expires_at IS NOT NULL AND expires_at < now() - INTERVAL '5 years';
Redis sits between the API and PostgreSQL. For our 1:100 write-to-read ratio, ~90% of redirects should hit Redis. That is the difference between a healthy system and a database on fire.
Redis is a fast in-memory data structure server, not just a KV cache. It supports strings, hashes, lists, sets, sorted sets, and pub/sub. For us it is mostly a string store: SET url:00000Q "https://...". Sub-millisecond reads because everything lives in RAM.
A single Redis node maxes out around 100K ops/sec. Not enough for us. Redis Cluster splits the keyspace into 16,384 hash slots. Each shard owns a contiguous range. The slot for any key is CRC16(key) mod 16384. Resharding moves slots without downtime.
slot = CRC16(key) mod 16384 · each shard owns a contiguous slot range
MOVED redirects.Redis can survive restarts via two persistence modes (you can combine them):
| Mode | How | Trade-off |
|---|---|---|
| RDB (snapshots) | Periodic fork() writes entire dataset to disk. | Fast restart. Lose up to N minutes on crash. |
| AOF (append log) | Append every write command to a log. Replay on restart. | At most 1 second of data loss with fsync=everysec. Slower restart. |
Each primary has 1 to 2 replicas. Redis Sentinel monitors primaries. If a quorum agrees a primary is dead, Sentinel promotes a replica and updates clients. Failover happens in seconds.
async function getLongUrl(code: string): Promise<string | null> {
const cached = await redis.get(`url:${code}`);
if (cached) return cached; // HIT: 90% of traffic
const row = await pg.query(
"SELECT long_url FROM urls WHERE short_code = $1",
[code]
);
if (!row) return null;
await redis.setex(`url:${code}`, 3600, row.long_url); // populate for 1h
return row.long_url;
}Redis is fast but volatile. PostgreSQL is durable. Every URL mapping lives here.
CREATE TABLE urls ( id BIGSERIAL PRIMARY KEY, short_code VARCHAR(8) NOT NULL UNIQUE, long_url TEXT NOT NULL, user_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ, click_count BIGINT NOT NULL DEFAULT 0 ); CREATE INDEX idx_short_code ON urls USING btree (short_code);
The UNIQUE constraint creates a B-tree index. Lookups take O(log n) page reads. For our 100M-row table, that is ~3 page reads, ~9ms on cold cache.
SELECT long_url FROM urls WHERE short_code = '2JjVS' · O(log n)
INSERT appends to the WAL (Write-Ahead Log) on the primary. A wal_sender process streams each record to every replica's wal_receiver, which replays them in order. Replicas serve SELECT traffic, scaling reads for our 1:100 write-to-read ratio.One primary cannot absorb 7,600 reads/sec at sustained load. PostgreSQL streams every WAL record to replicas in near-real-time (~10ms lag). The API routes SELECT queries to replicas and INSERT to the primary.
hash(short_code) into N shards. Each shard handles 1/N of writes. PostgreSQL does not do this natively; use Citus or app-level sharding.The redirect path must be fast. Doing UPDATE click_count++ on every redirect would create a hot row, lock contention, and tank latency. Solution: the API publishes a click event to Kafka and returns the 302 immediately. A worker pool consumes events and writes batched rollups to ClickHouse.
The clicks topic has 3 partitions (production: dozens). Same key always lands in the same partition. This preserves per-URL ordering. Each partition is an immutable append-only log with its own consumer offset.
partition = hash(short_code) mod 3 · replication factor 3 · consumer group tracks offset per partition
PostgreSQL is a row store. ClickHouse is columnar. Row stores are great for SELECT * on one row, terrible at SUM(clicks) GROUP BY day over billions. Columnar reads only the columns it needs and compresses them 10 to 100 times.
| Query | PostgreSQL (row) | ClickHouse (column) |
|---|---|---|
SELECT * WHERE id=42 | ~1 ms | ~50 ms (overhead) |
SUM(clicks) GROUP BY day over 1B rows | Minutes | ~100 ms |
INSERT 1 row | Fast | Slow (batch only) |
The worker batches click events (e.g. 1000 per insert) from Kafka into ClickHouse. Dashboards query ClickHouse, never PostgreSQL. This is the standard split: OLTP for the hot path, OLAP for analytics.
At scale, failure is not the exception. It is a daily event. With 10,000 disks across the fleet and MTTF of 10 years per disk, expect ~3 disk failures every single day. Reliable systems do not try to eliminate failure. They survive it.
| Type | Behavior | How we handle it |
|---|---|---|
| Hardware | Usually independent. One disk fails, others do not. | Replicas (PostgreSQL streaming, Kafka RF=3, Redis Sentinel). |
| Systematic | Correlated. One bug crashes every replica at once. | Far more dangerous. Canary deploys, feature flags, gradual rollouts. |
max_fails.| Requirement | How we meet it |
|---|---|
| Availability | Replication at every layer, GSLB across regions, daily S3 backups, rate limiters at the edge. |
| Scalability | Stateless API replicas, Redis Cluster hash slots, PostgreSQL read replicas + hash-sharding when needed, Kafka partitioning. |
| Readability | Base58 removes 0/O/I/l ambiguity and avoids URL-unsafe +/. |
| Latency | Redis cache absorbs ~90% of traffic at <1ms. PostgreSQL B-tree at ~9ms on miss. Encoding is O(1). |
| Unpredictability | Random ID selection within each server's assigned range. Optional salt before encoding hardens against guessing. |
By construction. We use a counter (INCR), not a hash. Atomic INCR is globally unique. Hash/random needs retry-on-collision, which does not scale.
302. A 301 is cacheable forever by browsers and CDNs. They never hit our service again, so we lose analytics. 302 is temporary, not cached, every click reaches us.
Redirects still work.Kafka is off the hot path. The producer buffers events in memory. If Kafka stays down past the buffer limit, we drop events. Click counts go stale, the core service stays up. That is async's whole point.
nginx limit_req caps them at 5 req/s per IP. They get 429s. For distributed attackers, add per-user-account limits at the API level and CAPTCHA on signup.
sho.rt/my-blog?Same table, just INSERT the user-supplied short_code. The UNIQUE constraint protects against collisions. Reserve a namespace prefix (e.g. generated codes start with a digit) so user aliases cannot collide with the counter sequence.
Silent data corruption. If you write to Redis first and PostgreSQL fails, the user sees a working short URL that was never persisted. Always write to PostgreSQL first (source of truth), then populate cache. Never the other way around.
Discussion
…