Overview
Design a URL shortener that:
- Creates a short 7-character alias for any long URL
- Redirects
short.ly/abc1234to the original URL in < 10ms p99 - Handles 100M URLs, 10B redirects/month (~3,850 redirects/second)
- Tracks click analytics (count, geo, device)
- Custom aliases (user-defined short codes)
Read/write ratio: ~1000:1 (many redirects per creation). Optimize hard for reads.
Architecture
┌────────────────────────────────────────────────────────────┐
│ Clients │
└────────────┬─────────────────────────┬───────────────────┘
│ POST /shorten │ GET /abc1234
▼ ▼
┌────────────────────────────────────────────────────────────┐
│ CDN (Cloudfront) │
│ (cache 301/302 redirects at edge) │
└────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌────────────────────────────────────────────────────────────┐
│ API Gateway + Load Balancer │
└─────────┬──────────────────────┬────────────────────────┘
│ │
┌──────▼──────┐ ┌───────▼──────┐
│ URL Write │ │ URL Read │
│ Service │ │ Service │
│ (Create) │ │ (Redirect) │
└──────┬──────┘ └───────┬──────┘
│ │
│ ┌──────▼──────┐
│ │ Redis │
│ │ Cache │
│ │ (shortCode │
│ │ → longUrl) │
│ │ TTL: 24hr │
│ └──────┬──────┘
│ │ cache miss
│ ▼
│ ┌─────────────────────────┐
└───────▶│ Cassandra / DynamoDB │
│ Primary store │
│ │
│ PK: short_code (text) │
│ long_url │
│ user_id │
│ created_at │
│ expires_at │
│ custom: bool │
└──────────────────────────┘
│
┌─────────▼────────────────┐
│ Analytics Pipeline │
│ │
│ Click events → │
│ Kafka → │
│ Stream processor → │
│ ClickHouse (OLAP) │
└──────────────────────────┘
Technical Implementation
Short Code Generation — Two Approaches
Approach 1: Counter + Base62 (Recommended)
Global counter (auto-increment) → encode in base62
base62 alphabet: 0-9, a-z, A-Z (62 chars)
7 chars → 62^7 = 3.5 trillion unique codes
Counter 1000 → base62 → "g8" (short)
Counter 1000000 → base62 → "4c92"
Pros: predictable length, no collisions
Cons: sequential codes reveal scale (enumerable)
Approach 2: Hash (MD5/SHA256) + truncate
MD5("https://example.com/products/abc-123")
→ "5d41402abc4b2a76b9719d911017c592"
→ take first 7 chars → "5d41402"
→ base62 encode → "abc1234"
Pros: not guessable
Cons: collisions possible (check DB, retry with different offset)
// src/services/url-shortener.service.ts
import { createHash } from 'crypto';
import { DynamoDBDocumentClient, PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb';
const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export class UrlShortenerService {
async shorten(longUrl: string, customCode?: string, userId?: string): Promise<string> {
const shortCode = customCode ?? (await this.generateUniqueCode(longUrl));
await this.db.send(new PutCommand({
TableName: 'urls',
Item: {
shortCode,
longUrl,
userId: userId ?? 'anonymous',
createdAt: new Date().toISOString(),
expiresAt: null,
},
ConditionExpression: 'attribute_not_exists(shortCode)', // prevent overwrite
}));
return shortCode;
}
async resolve(shortCode: string): Promise<string | null> {
// 1. Check Redis cache first
const cached = await this.redis.get(`url:${shortCode}`);
if (cached) return cached;
// 2. Fall back to DynamoDB
const result = await this.db.send(new GetCommand({
TableName: 'urls',
Key: { shortCode },
}));
if (!result.Item) return null;
// 3. Cache for 24 hours
await this.redis.setex(`url:${shortCode}`, 86400, result.Item.longUrl);
return result.Item.longUrl;
}
private async generateUniqueCode(longUrl: string): Promise<string> {
const hash = createHash('md5').update(longUrl).digest('hex');
for (let offset = 0; offset <= hash.length - 7; offset++) {
const candidate = this.base62Encode(parseInt(hash.slice(offset, offset + 7), 16));
const exists = await this.codeExists(candidate);
if (!exists) return candidate;
}
// Fallback: random code
return this.base62Encode(Math.floor(Math.random() * 62 ** 7));
}
private base62Encode(num: number): string {
let result = '';
while (num > 0) {
result = BASE62[num % 62] + result;
num = Math.floor(num / 62);
}
return result.padStart(7, '0');
}
}
Redirect Handler — Optimize for Speed
// app/api/[shortCode]/route.ts — Next.js handler
export async function GET(req: Request, { params }: { params: { shortCode: string } }) {
const longUrl = await urlService.resolve(params.shortCode);
if (!longUrl) {
return Response.redirect('https://short.ly/not-found', 302);
}
// Track analytics fire-and-forget (never block the redirect)
trackClick(params.shortCode, req).catch(console.error);
// 301 = permanent (browsers cache, reduces load — but can't change destination)
// 302 = temporary (no client cache — use for analytics-tracked short URLs)
return Response.redirect(longUrl, 302);
}
async function trackClick(shortCode: string, req: Request): Promise<void> {
await kafka.emit('url.clicks', {
shortCode,
timestamp: Date.now(),
userAgent: req.headers.get('user-agent'),
referer: req.headers.get('referer'),
// IP geolookup done downstream
});
}
Interview Preparation
Q: 301 vs 302 redirect — which do you use?
302 (temporary) for short URL services. 301 (permanent) tells browsers to cache the redirect permanently — they stop hitting your server, you lose analytics. 302 ensures every click goes through your server, enabling click counting and A/B experiments.
Q: How do you scale to 100M URLs?
Cassandra or DynamoDB — both partition by short_code, scale horizontally, handle millions of writes/sec. The read path is almost entirely from Redis cache (10B/month = 3850 RPS with ~99% cache hit rate → real DB load is ~40 RPS). Redis cluster with read replicas handles this trivially.
Q: How do you prevent enumeration attacks (guessing short codes)?
Use hash-based codes (not sequential counters). Add a random salt to the hash input. Implement rate limiting on the redirect endpoint per IP. Monitor for sequential access patterns. Add CAPTCHA for suspicious IPs.
Q: Custom aliases — how do you prevent squatting on reserved words?
Block list of reserved slugs (api, admin, login, about, health). Min length 4 characters for custom aliases. Rate limit custom alias creation per user account. Periodic audit for offensive/trademark terms.
Q: How does the analytics pipeline scale?
Click events emit to Kafka. Stream processors (Kafka Streams / Flink) aggregate counts in 1-minute windows. Write aggregated counts to ClickHouse (columnar OLAP DB, excellent for time-series queries). API queries ClickHouse for per-URL analytics dashboards — can handle billions of rows with sub-second queries.
Learning Resources
- System Design Interview Vol. 1 — Alex Xu, Chapter 8
- Bitly Engineering Blog — at-scale URL shortener insights
- DynamoDB Best Practices
- ClickHouse Docs — analytics at scale