10 · Project — Design a URL Shortener¶
This project pulls together every Level 1 lesson. Try it yourself first: 45 minutes, paper, and the five-step approach from lesson 1. Then read the walkthrough below and compare decision by decision. Your design does not need to match — but you should be able to defend every place it differs.
Step 1 — Requirements¶
Functional
- Create a short link for a long URL; optionally choose a custom alias.
- Visiting the short link redirects to the long URL.
- Links may have an expiry time.
- The creator can see a click count per link.
Out of scope: user accounts beyond an API key, link editing, spam/malware scanning (mentioned as a follow-up), detailed analytics dashboards.
Non-functional
- Redirects are the hot path: low latency (target p99 under ~50 ms server-side) and high availability — a broken shortener breaks every link ever shared.
- Short links should not be trivially enumerable.
- Once created, a mapping must never be lost or silently changed.
- Click counts can be slightly delayed (seconds to minutes) and approximate.
Step 2 — Estimates¶
Note
Assumptions are illustrative. The arithmetic is the skill being practised.
New links: 100M per month -> 100M / (30 × 10^5 s) ≈ 35/s avg, ~100/s peak
Redirects: 100:1 read ratio -> ≈ 3,500/s avg, ~10,000/s peak
Record size: ~500 bytes (URL up to 2 KB, but average far lower) + metadata
Storage: 100M × 500 B = 50 GB/month ≈ 600 GB/year ≈ 3 TB over 5 years
Conclusions: writes are light; reads are heavy but tiny; data fits comfortably in one database for years, but reads should mostly be served from cache. The redirect path's availability is the most important property.
Step 3 — API and data model¶
POST /api/links
headers: Authorization: Bearer <api key>, Idempotency-Key: <uuid>
body: {"url": "https://...", "alias": "optional", "expires_at": "optional"}
201 -> {"code": "aZ3kP9q", "short_url": "https://sho.rt/aZ3kP9q"}
GET /{code} -> 301/302 Location: <long url> | 404 | 410 if expired
GET /api/links/{code}/stats -> {"clicks": 1234}
CREATE TABLE links (
code VARCHAR(16) PRIMARY KEY,
long_url TEXT NOT NULL,
owner_key VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NULL
);
CREATE TABLE click_counts (
code VARCHAR(16) PRIMARY KEY,
clicks BIGINT NOT NULL DEFAULT 0
);
301 or 302? A 301 (permanent) lets browsers cache the redirect, so repeat visits
never reach us — cheaper, but we lose click counts and cannot change or expire the link
for those browsers. A 302/307 (temporary) sends every visit to us. Because we promised
click counts and expiry, choose 302, and set a short Cache-Control if desired.
Step 4 — Generating short codes¶
This is the most-discussed decision. Options:
| Approach | How | Pros | Cons |
|---|---|---|---|
| Hash the URL | Take first 7 chars of base62(SHA-256(url)) | Same URL → same code | Collisions must be handled; same URL from two users shares a code (maybe unwanted) |
| Counter + base62 | Global counter, encode in base62 | No collisions, short | Sequential → enumerable; a single counter is a bottleneck/SPOF |
| Random | 7 random base62 chars, insert with uniqueness check | Not enumerable, simple, no coordination | Collision retry needed (rare while space is sparse) |
| Pre-generated key pool | Background job creates unused random codes; servers claim batches | Fast issue, no collision on hot path | Extra component; must not hand the same batch out twice |
Capacity check for 7 base62 characters: 62⁷ ≈ 3.5 × 10¹². After 5 years we hold about 6 × 10⁹ links, filling roughly 0.2% of the space, so a random code collides with an existing one about 1 time in 600 — a single retry handles it.
We choose random codes with a uniqueness constraint:
# shortener.py — core logic with SQLite standing in for the real database
import secrets, sqlite3, string, time
ALPHABET = string.ascii_letters + string.digits # 62 characters
db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE links(code TEXT PRIMARY KEY, long_url TEXT NOT NULL,
created_at REAL NOT NULL, expires_at REAL)""")
def new_code(n=7):
return "".join(secrets.choice(ALPHABET) for _ in range(n))
def create(long_url, alias=None, ttl=None, attempts=5):
if not long_url.startswith(("http://", "https://")):
raise ValueError("only http(s) URLs allowed")
expires = time.time() + ttl if ttl else None
for _ in range(1 if alias else attempts):
code = alias or new_code()
try:
with db: # transaction
db.execute("INSERT INTO links VALUES (?,?,?,?)",
(code, long_url, time.time(), expires))
return code
except sqlite3.IntegrityError: # code already taken
if alias:
raise ValueError("alias taken")
raise RuntimeError("could not allocate a code")
def resolve(code):
row = db.execute("SELECT long_url, expires_at FROM links WHERE code=?",
(code,)).fetchone()
if row is None:
return 404, None
if row[1] is not None and row[1] < time.time():
return 410, None
return 302, row[0]
c = create("https://example.com/some/very/long/path?x=1")
print(c, resolve(c)) # e.g. 'aZ3kP9q' (302, 'https://example.com/...')
print(resolve("nope123")) # (404, None)
print(create("https://example.org", alias="docs"), resolve("docs"))
secrets rather than random matters: codes should be unpredictable.
Step 5 — High-level architecture¶
flowchart LR
U[Browser] --> CDN[CDN / edge]
CDN --> LB[Load balancer]
LB --> R1[Redirect service]
LB --> R2[Redirect service]
R1 --> C[(Cache)]
R2 --> C
C -. miss .-> DB[(Links DB primary + replicas)]
R1 -- click events --> Q[[Queue]]
R2 -- click events --> Q
Q --> W[Counter worker] --> CC[(click_counts)]
API[Create API] --> DB
- Redirect service: stateless; looks up
codein the cache, falls back to a read replica, returns 302. Mappings are immutable, so caching is nearly free of staleness problems — only expiry and deletion need care (cache with a TTL no longer than the link's remaining lifetime). - Cache sizing: link popularity is highly skewed. Caching the hottest ~20% of recent links (a few tens of GB at most by our estimate) should serve the large majority of redirects.
- Click counting: incrementing a database row on every redirect would put 10,000
writes/s on hot rows. Instead, the redirect service emits a click event to a queue and
returns immediately. A worker aggregates events in memory and flushes
UPDATE click_counts SET clicks = clicks + ?every few seconds. Counts lag slightly — which the requirements allow. - Create API: writes to the primary. The
Idempotency-Keyheader makes a retried create return the same code instead of making a second link.
Step 6 — Deep dives and failure modes¶
- Database primary down: redirects keep working from cache and replicas; creates fail until failover. Acceptable given priorities.
- Cache down: all redirects hit replicas. At ~10,000/s of primary-key reads, several replicas can likely absorb it, so provision them for that case rather than for the warm-cache case alone.
- Queue down: redirects must not fail because counting failed. Drop or locally buffer click events; counts become approximate.
- Abuse: shorteners are used to disguise malicious links. Rate-limit creation per API key (Level 2, lesson 6) and scan URLs asynchronously; flagged codes return a warning page.
- Hot link: a link shared by a celebrity can take a large share of traffic. It is cached everywhere, and the CDN can cache the 302 for a few seconds to absorb spikes.
How It Actually Works¶
Why the redirect is cheap. A redirect is one primary-key lookup and a response of a
few hundred bytes with a Location header. The browser does the rest: on a 302 it issues
a new request to the long URL, which is someone else's server. Our service never
proxies content, so bandwidth stays tiny even at high request rates.
Why random IDs need no coordination. Every server generates codes independently from
a cryptographic random source. The only coordination is the database's uniqueness check
on insert, which is local to one partition if we later shard by code. Contrast a
global counter, which requires every create to touch one shared value — a scaling limit
and a single point of failure unless you hand out counter ranges in blocks.
Why counters go through a queue. Updating the same row thousands of times per second
serializes on that row's lock. Aggregating in a worker converts thousands of +1
writes into one +N write per interval: the same total, with orders of magnitude fewer
database operations.
Common mistakes¶
- Using a 301 and then promising click analytics.
- Sequential codes that let anyone enumerate every link ever created.
- Updating counters synchronously on the redirect path.
- Forgetting to validate URLs (e.g.
javascript:schemes) before storing them. - Sizing the database for the warm-cache case only.
Exercise¶
- Extend
shortener.pywith an in-process LRU cache from lesson 6 in front ofresolve, and ensure expired links are never served from cache. - Add an idempotency-key table so repeated
createcalls with the same key return the same code. - Suppose requirements change: 10 billion new links per month. Redo the estimates. Which parts of the design break first, and what would you change? (You will have better tools for this after Level 2's sharding lessons — come back and revise.)
- Write a one-page design doc for your version, including a "rejected alternatives" section explaining why you did not choose the other ID-generation schemes.