Designing a URL Shortener


distributed-systems databases api-design

Overview

A URL shortener takes a long URL and maps it to a short, unique alias. When users visit the short URL, they get redirected to the original.

How do you Proceed?

URL shortener system design - interview explanation guide

1. Opening pitch
2. Core flow
3. Code generation
4. Storage
5. Scalability
6. Tradeoffs
7. Follow-ups
Q: “Design a URL shortener like bit.ly”
Start with a crisp one-liner to anchor the conversation:“A URL shortener maps a long URL to a short, unique code. When someone visits the short link, we look up the code, find the original URL, and redirect them - ideally in under 10ms.”Then immediately clarify scope. Interviewers love candidates who ask the right questions before jumping to code.
Say this: “Before I dive in - should I focus on the core shorten/redirect flow, or also cover analytics, custom aliases, and rate limiting?”
Q: “Walk me through the end-to-end flow”
Draw out the two core paths clearly - write path (creating a short link) and read path (redirecting).
Write path
Client POST /shorten
Validate URL
Generate code
Save to DB
Return short URL
Read path (the hot path)
GET /aB3xKz
Cache lookup
DB fallback
301 Redirect
Emphasise the read path is 100x more frequent than write. That’s why we cache aggressively. A 301 (permanent) redirect lets browsers cache it client-side; a 302 (temporary) forces every click through our server - useful for accurate click analytics.
Say this: “The read path is the critical path. At scale, reads dwarf writes, so I’d put Redis in front of the DB for O(1) lookups.”
Q: “How do you generate the short code?”
This is a common deep-dive. Explain the three main approaches and why you chose Base62.
// Base62 alphabet - 62 characters ’0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz’ // 6 chars → 62^6 = ~56 billion combinations function generateCode(): string { const bytes = crypto.getRandomValues(new Uint8Array(6)); return […bytes].map(b => BASE62[b % 62]).join(); }
Three approaches to mention:Option 1 - Random Base62 (what we built): generate 6 chars, check DB for collision, retry if needed. Simple, works at scale.Option 2 - MD5/SHA hash of URL: deterministic, same URL always gets same code. Problem: long URLs from different users sharing a code unexpectedly.Option 3 - Auto-increment ID → Base62: no collision risk, but ID is guessable (sequential). Can fix with adding a random salt.
Say this: “I prefer random Base62 - collision probability with 6 chars is negligible at typical scale, and it’s simple to reason about.”
Q: “What’s your storage model?”
Talk about the repository pattern - you define a contract (IUrlRepository) and swap implementations. This is a design maturity signal interviewers love.
// Contract - the interface interface IUrlRepository { save(record: UrlRecord): Promise<void>; findByCode(code: string): Promise<UrlRecord | null>; incrementClicks(code: string): Promise<void>; }
Then explain the schema:Primary table - url_records(id, short_code, original_url, created_at, expires_at, clicks, user_id)Index on short_code (the hot lookup). For analytics, you’d separate click events into a write-optimised table or stream them to Kafka.
Say this: “I use a repository interface so the service layer has zero knowledge of whether we’re using Postgres, DynamoDB, or Redis. Swapping storage is a one-class change.”
Q: “How does this scale to millions of requests?”
This is where you demonstrate systems thinking. Cover three layers:
CDN / Edge
Cache popular short codes at the edge - redirect without hitting origin
Redis cache
Hot codes live in Redis - sub-millisecond lookup, LRU eviction
Read replicas
DB read replicas handle lookup fan-out; primary only for writes
Async clicks
Increment click count via a queue (Kafka/SQS) - don’t block redirect
Say this: “The redirect should never wait on a DB write. I’d publish click events to a queue and update counters asynchronously - keeps p99 latency tight.”
Q: “What tradeoffs did you make?”
Interviewers want to see you reason about tradeoffs, not just recite patterns. Hit these two:
301 vs 302 redirect
301 - permanent

Browsers cache it. Fewer requests to your servers. Better UX speed.

301 downside

Can’t track every click - browser skips your server after first visit.

Random code vs hash of URL
Random code

Simple, unique per request, works for same URL submitted multiple times.

Hash of URL

Same URL = same code (deduplication), but hash collisions need careful handling.

Say this: “I chose 302 if analytics matter to the business, because every redirect goes through our server and we can count it accurately.”
Common follow-up questions
Be ready for these - they test depth:
“How do you handle expired links?”
Store expires_at in the record. Check it at resolve time - if expired, return 410 Gone. A background job (cron) cleans up old rows periodically. Redis TTL handles cache eviction automatically.
”How do you prevent abuse / spam URLs?”
Rate limit by IP on the POST endpoint (token bucket). Check the URL against a blocklist (Google Safe Browsing API). Require auth for high-volume users.
”How do you scale to 100k requests/sec?”
Horizontally scale stateless API servers behind a load balancer. Redis cluster for caching. Shard the DB by short_code hash if needed. Use CDN to offload the most popular links entirely.
”Why TypeScript specifically?”
Interfaces like IUrlRepository and UrlRecord make contracts explicit. Custom error classes (UrlNotFoundError) make error handling typed and exhaustive. The compiler catches mismatches before runtime.

Interactive Demo with Minimal code in typescript

Overview
Types
Service
Storage
API Layer
Live Demo
Architecture Overview
Client
POST /shorten
GET /:code
API Layer
Express routes
Validation middleware
UrlService
Business logic
Code generation
Storage
IUrlRepository
In-mem / Redis / DB
File Structure
// Project structure src/   ├── types/   │   └── url.types.ts // interfaces & DTOs   ├── storage/   │   ├── IUrlRepository.ts // repository contract   │   ├── InMemoryRepo.ts // in-memory impl   │   └── RedisRepo.ts // Redis impl   ├── services/   │   └── UrlService.ts // core business logic   ├── middleware/   │   └── validate.ts // request validation   ├── routes/   │   └── url.routes.ts // Express routes   └── server.ts // entry point
Key Design Decisions
Repository Pattern
Swap storage backends without touching business logic
Base62 Encoding
6-char codes → 56B combinations, URL-safe
TTL Support
Optional expiry on every link, enforced at read time
types/url.types.ts
url.types.tsTypeScript
export interface UrlRecord { id: string; shortCode: string; originalUrl: string; createdAt: Date; expiresAt?: Date; clicks: number; userId?: string; alias?: string; // custom short slug } export interface CreateUrlDto { originalUrl: string; alias?: string; ttlSeconds?: number; userId?: string; } export interface UrlResponse { shortUrl: string; shortCode: string; originalUrl: string; expiresAt?: Date; } export class UrlNotFoundError extends Error { constructor(code: string) { super(`Short code not found: ${code}`); this.name = ‘UrlNotFoundError’; } } export class UrlExpiredError extends Error { constructor() { super(‘This link has expired’); this.name = ‘UrlExpiredError’; } }
services/UrlService.ts
UrlService.tsCore Logic
const BASE62 = ‘0123456789ABCDEF…z’; const CODE_LEN = 6; export class UrlService { constructor(private repo: IUrlRepository, private baseUrl: string) {} async shorten(dto: CreateUrlDto): Promise<UrlResponse> { this.validateUrl(dto.originalUrl); const code = dto.alias ? await this.reserveAlias(dto.alias) : await this.generateUniqueCode(); const record: UrlRecord = { id: crypto.randomUUID(), shortCode: code, originalUrl: dto.originalUrl, createdAt: new Date(), clicks: 0, }; await this.repo.save(record); return this.toResponse(record); } async resolve(code: string): Promise<string> { const record = await this.repo.findByCode(code); if (!record) throw new UrlNotFoundError(code); if (record.expiresAt && record.expiresAt < new Date()) throw new UrlExpiredError(); await this.repo.incrementClicks(code); return record.originalUrl; } private generateCode(): string { let code = ; const bytes = crypto.getRandomValues( new Uint8Array(CODE_LEN)); for (const b of bytes) code += BASE62[b % 62]; return code; } }
storage/IUrlRepository.ts + InMemoryRepo.ts
IUrlRepository.tsInterface
export interface IUrlRepository { save(record: UrlRecord): Promise<void>; findByCode(code: string): Promise<UrlRecord | null>; incrementClicks(code: string): Promise<void>; deleteByCode(code: string): Promise<boolean>; aliasExists(alias: string): Promise<boolean>; }
InMemoryRepo.tsImplementation
export class InMemoryUrlRepository implements IUrlRepository { private store = new Map<string, UrlRecord>(); async save(r: UrlRecord) { this.store.set(r.shortCode, r); } async findByCode(code: string) { return this.store.get(code) ?? null; } async incrementClicks(code: string) { const r = this.store.get(code); if (r) r.clicks++; } async aliasExists(alias: string) { return […this.store.values()] .some(r => r.alias === alias); } }
routes/url.routes.ts
url.routes.tsExpress
const router = Router(); const svc = new UrlService(repo, https://snip.ts); // POST /api/shorten router.post(‘/shorten’, validate(schema), async (req, res) => { const result = await svc.shorten(req.body); res.status(201).json(result); }); // GET /:code - redirect router.get(’/:code’, async (req, res) => { const url = await svc.resolve(req.params.code); res.redirect(301, url); }); // GET /api/stats/:code router.get(‘/stats/:code’, async (req, res) => { const stats = await svc.getStats(req.params.code); res.json(stats); });
middleware/validate.tsZod
import { z } from ‘zod’; export const createSchema = z.object({ originalUrl: z.string().url(), alias: z.string() .regex(/^[a-z0-9-]{3,30}$/) .optional(), ttlSeconds: z.number().int() .positive().optional(), }); export function validate(schema: ZodSchema) { return (req, res, next) => { const result = schema.safeParse(req.body); if (!result.success) return res.status(400).json({ errors: result.error.flatten() }); req.body = result.data; next(); }; }
Live Demo - In-memory simulation
0
LINKS CREATED
0
TOTAL CLICKS
0
ACTIVE LINKS
Shorten
Resolve
All Links

Comments