Snipp
A URL shortener built as a full system-design exercise — requirements and capacity estimation written before any code, then a Redis cache layer, rate limiting and click analytics measured against those numbers.
Tech Stack
Features
- ⚡ 44ms p99 redirect - Redis-backed read path, 44× the throughput of the uncached baseline
- 🔗 Custom aliases - With a reserved-word blacklist so links can’t collide with app routes
- 📊 Click analytics - Time, device and referrer, written async so they never slow a redirect
- 🔒 Sliding window rate limiting - One Lua script per check, so concurrent requests can’t race past the limit
- ⏳ Expiry dates - Enforced through the cache TTL, not just at query time
- 🔐 Auth + dashboard - JWT sessions, per-user link management
- 📱 QR codes - Generated per link
- 🛡️ SSRF-safe - URL validation plus a private IP range blacklist
The central design point
Snipp looks like a CRUD app, but it has two data paths with completely different characteristics:
| Write path (create) | Read path (redirect) | |
|---|---|---|
| Traffic | ~0.4 req/s | ~39 req/s (100×) |
| Latency budget | 300ms (p95) | 50ms (p99) |
| Cache | None | Redis is the primary path |
So the redirect path doesn’t go through Next.js at all — both share one domain, split by path at the edge.
Benchmark
Redirect path, 50 connections, autocannon, dev machine — the value is in the ratios, not the absolute numbers.
| State | req/s | p99 | clicks recorded |
|---|---|---|---|
| no-cache (baseline) | 74 | 836ms | 100% |
| cache-only | 2,241 | 35ms | 3% |
| cache-hit (real traffic) | 3,282 | 44ms | 100% |
The interesting row is cache-only: latency looked nearly perfect while 97% of clicks silently vanished. Caching the read path removed the connection-pool contention that had been acting as accidental backpressure, so MySQL got ~4,500 writes/s it couldn’t absorb — and since clicks are fire-and-forget, nothing surfaced. Removing a bottleneck without replacing it turns a latency problem into a data-loss problem.
Decisions worth naming
- 302, not 301 - A 301 is cached permanently, so the second click never reaches the server: no analytics, no editable target, no expiry.
- Random base62, not encoded sequential ids - Sequential codes let anyone enumerate every link by counting up.
- Cache the misses too - Otherwise a bot walking
/aaaaaa,/aaaaab… reaches MySQL on every request. - TTL =
min(24h, time until expiry)- Otherwise a link expiring in 5 minutes keeps serving for nearly a day. - Rate limiting fails open, and skips redirects - Failing closed makes Redis a single point of failure; and a viral link looks exactly like an attack, so the cache protects that path instead.
Capacity estimation changed the design
The clicks table works out to 97% of storage — ~240 GB/year, forty times the links table. So raw clicks are kept 90 days, and the dashboard queries an aggregate table rather than ever running COUNT(*) over raw rows.