Design a URL Shortener (bit.ly / tinyurl)
The classic first system design interview question. It's small enough to master in one sitting, but rich enough to test everything a Senior engineer is judged on: requirements, capacity math, hashing, caching, and trade-offs. We'll design it as a .NET developer — with real C# and SQL.
Why this question?
Interviewers love the URL shortener because it looks trivial — "a table and two endpoints, right?" — but it secretly tests 4 deep topics:
- Hashing & encoding — how do you turn a long URL into 7 characters safely?
- Database design — schema, indexes, unique constraints, SQL vs NoSQL.
- Caching — the read path is 100× the write path; that's a cache problem.
- HTTP semantics — 301 vs 302, and why a senior knows the difference matters.
Master this one and you have the skeleton of half the questions you'll ever face — the read-heavy, write-light, hash-keyed lookup pattern.
🎯 By the end of this lesson you can:
- Ask the 6 clarifying questions that turn a vague question into a solvable spec
- Estimate QPS and storage for any read-heavy system from memory
- Explain base62 encoding and when truncating a hash is (and isn't) safe
- Design a cache-aside Redis layer in C# with
IDistributedCache - Defend the 301-vs-302 decision and the SQL-vs-NoSQL decision with trade-offs
- Run the whole 25-minute design conversation out loud, in English
The conversation: ask before you build
Rule #1 of system design: a senior never starts drawing. They start asking. The interviewer gives you a one-line question on purpose — the real test is what you ask next. Here is the exact conversation:
| Your question | Typical answer | Why it matters |
|---|---|---|
| How many new URLs per day? | 100 million | Sets the write scale → server count, DB size |
| Read vs write ratio? | 100 : 1 (10B reads/day) | Reads dominate → caching is non-negotiable |
| How long must URLs live? | ~5 years, some expire | Storage math + cleanup design |
| Max length of long URL? | ~2,048 chars | Storage per row, TEXT column choice |
| Custom aliases needed? | Nice-to-have | Extra validation + collision handling |
| Latency target? | p95 < 100 ms | Forces cache in front of DB |
| Analytics needed? | Later (async events) | Drives 302 choice + queue (Kafka) |
Capacity estimation (back of the envelope)
You don't need a calculator. You need round numbers and unit sanity:
| Metric | Calculation | Result |
|---|---|---|
| Write QPS | 100M / 86,400 s ≈ 1,200/s | One DB handles writes easily |
| Read QPS | 10B / 86,400 s ≈ 115,000/s (peak ~2×) | Need cache + many app servers |
| Row size | code + URL + timestamps ≈ 500 bytes | — |
| Storage / year | 100M × 500 B ≈ 50 GB/day ≈ 18 TB/year | 5 years ≈ 90 TB → archive old rows |
| Code space | base62 = 62 chars; 62⁷ ≈ 3.5 trillion | 7 chars is plenty for years |
| Bandwidth (reads) | 115K/s × ~200 B redirect | ~23 MB/s — trivial for any LB |
API design
Two endpoints. That's the whole surface. Note the redirect uses 302 — we'll defend that in Deep Dive 4.
// Create a short URL POST /api/urls { "longUrl": "https://very-long.example.com/blog/2026/..." } → 201 Created { "shortUrl": "https://sho.rt/Ab3xY9z", "code": "Ab3xY9z" } // Follow a short URL GET /{code} // e.g. GET /Ab3xY9z → 302 Found Location: https://very-long.example.com/blog/2026/...
400 for invalid URL,
404 for unknown code, 429 when rate-limited,
410 Gone for expired URLs. Interviewers notice complete thinking.
High-level design
One diagram, two flows. Write path (create) and read path (redirect). The read path goes cache-first — that's the whole game.
| Component | Job | Notes for the interview |
|---|---|---|
| Load balancer | Spread traffic, health checks | App servers are stateless → horizontal scaling is trivial |
| App servers (.NET) | Handle both endpoints | Stateless: session/state lives in Redis, not in process memory |
| Redis cache | Serve hot redirects | Cache-aside, TTL 24–48 h, LRU eviction handles the 80/20 pattern |
| SQL database | Source of truth | Unique index on short_code; PostgreSQL preferred in modern .NET |
| Kafka (future) | Click analytics events | Async — never block the redirect on analytics |
Deep dives
5.1 — Generating the short code: hash vs ID
This is the question that separates juniors from seniors. Two valid schools:
School A — Hash the URL: MD5(longUrl) → 128 bits → base62 → first 7 chars.
- Pros: deterministic — the same URL always gets the same code; works offline; no DB dependency.
- Cons: collisions. 62⁷ ≈ 3.5T codes, but with 100M new URLs/day, birthday paradox makes collisions a real risk over years. Fix: on collision, re-hash with a salt (
MD5(longUrl + counter)) and retry.
School B — Global ID → base62: get a unique number from a sequence or snowflake, encode it.
- Pros: zero collision management — guaranteed unique; IDs are orderable (nice for sharding later).
- Cons: needs a DB call or ID service per write; codes are guessable in creation order.
5.2 — Database schema
CREATE TABLE urls ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, short_code VARCHAR(10) NOT NULL UNIQUE, long_url TEXT NOT NULL, user_id BIGINT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NULL ); -- hot lookup: the read path is a point query by code CREATE INDEX idx_urls_code ON urls (short_code);
UNIQUEonshort_codeis your collision safety net — the DB refuses duplicates, even if your hash logic slips.expires_at NULL= never expires. Cleanup via lazy delete on read + a nightly batch job.- Interview point: "reads are point lookups by code → this is a perfect relational fit; no NoSQL needed."
5.3 — Caching: the cache-aside pattern
The read path is 100× the write path, and redirects don't change often. Classic cache-aside:
- Read
GET /{code}→ check Redis first. - Hit → return 302 immediately (μs).
- Miss → query SQL → store in Redis with TTL → return 302.
- TTL of 24–48 h + LRU eviction keeps hot URLs alive automatically (80/20 rule).
5.4 — 301 vs 302 (the sneaky senior question)
| Status | Behavior | Cost / benefit |
|---|---|---|
| 301 Permanent | Browser caches the redirect; future clicks never hit our servers | Less load, great SEO — but no analytics: we only see the first click |
| 302 Temporary | Every click hits our servers | Full analytics + rate limiting, at the cost of more traffic |
For a shortener that wants click analytics → 302. For an asset CDN wanting max cache hits → 301. Saying this out loud in one sentence is worth a lot.
5.5 — Abuse & rate limiting
- Per-IP and per-user quotas on creation (e.g. 100/day anonymous).
- Malware/phishing checks on the long URL — async, after creation (never block the 201).
- Blocklist of known-bad domains; expire suspicious links fast.
5.6 — Scaling beyond one machine
- App layer: stateless already → add more servers behind the LB. Done.
- DB reads: add read replicas; redirects can read from replicas (eventual consistency is fine for a redirect).
- DB writes: when one writer is saturated, shard by
hash(short_code) % N— a single point lookup still hits exactly one shard. - Cache: Redis Cluster when memory grows past one node.
The trade-off matrix (memorize the shape, not the words)
| Decision | Options | Verdict |
|---|---|---|
| Code generation | MD5-truncate vs ID+base62 | ID+base62 PICK No collision management; accepts DB dependency |
| Database | PostgreSQL vs MongoDB | PostgreSQL PICK Point lookups + unique constraint + relational analytics |
| Redirect | 301 vs 302 | 302 PICK Analytics + abuse control; accept the traffic |
| Caching | Cache-aside vs write-through | Cache-aside PICK Simplest; DB stays source of truth |
| Scaling | Vertical vs horizontal | Horizontal PICK Stateless app servers; replicas + sharding for DB |
Reference implementation — .NET 10 Minimal API
Program.cs — the two endpoints
using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext<UrlDbContext>(); builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379"); var app = builder.Build(); // ── Write path: create ───────────────────────────── app.MapPost("/api/urls", async (CreateUrlRequest req, UrlDbContext db) => { if (!Uri.TryCreate(req.LongUrl, UriKind.Absolute, out var uri)) return Results.BadRequest("Invalid URL"); var entity = new UrlRecord { LongUrl = uri.ToString() }; db.Urls.Add(entity); await db.SaveChangesAsync(); // id is now assigned entity.ShortCode = EncodeBase62(entity.Id); // id → "Ab3xY9z" await db.SaveChangesAsync(); return Results.Created($"/{entity.ShortCode}", new { shortUrl = $"https://sho.rt/{entity.ShortCode}" }); }); // ── Read path: redirect (cache-aside) ────────────── app.MapGet("/{code}", async (string code, UrlDbContext db, IDistributedCache cache) => { var longUrl = await cache.GetStringAsync(code); // ① cache first if (longUrl is null) // ② miss → DB { var row = await db.Urls .FirstOrDefaultAsync(u => u.ShortCode == code); if (row is null) return Results.NotFound(); longUrl = row.LongUrl; await cache.SetStringAsync(code, longUrl, // ③ backfill cache new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) }); // TTL 24h } return Results.Redirect(longUrl, permanent: false); // ④ 302 → analytics }); app.Run(); // ── base62: turn a number into a short code ────────── static string EncodeBase62(long id) { const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; var sb = new System.Text.StringBuilder(); while (id > 0) { sb.Insert(0, chars[(int)(id % 62)]); id /= 62; } return sb.ToString().PadLeft(7, '0'); }
Two endpoints, ~40 lines. Cache-aside on reads, ID→base62 on writes, 302 on redirect.
UrlDbContext — the schema in EF Core
public sealed class UrlRecord { public long Id { get; set; } public string ShortCode { get; set; } = ""; public string LongUrl { get; set; } = ""; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? ExpiresAt { get; set; } } public sealed class UrlDbContext(DbContextOptions<UrlDbContext> o) : DbContext(o) { public DbSet<UrlRecord> Urls => Set<UrlRecord>(); protected override void OnModelCreating(ModelBuilder b) { b.Entity<UrlRecord>().HasIndex(u => u.ShortCode).IsUnique(); } }
The unique index on ShortCode is your collision safety net — the DB refuses duplicates.
Homework
- Custom aliases. Add
POST /api/urls { longUrl, alias? }. Handle the 409 Conflict case when the alias is taken. Write the validation rules. - Expiry. Design lazy expiry (check
expires_aton read, return 410 Gone) plus a nightly batch job that deletes rows older than X. What index does the batch job need? - Read replicas. Write the connection-string setup for read/write splitting (write → primary, redirect reads → replica). When is it safe, and when is it not?
- Analytics pipeline. Sketch the Kafka flow: every redirect emits an event (code, timestamp, referrer, user-agent). Draw the consumers and the aggregation tables. Why must this never block the 302?
- The drill (most important). Set a 25-minute timer. Walk through the whole design out loud — questions → numbers → API → diagram → deep dives → trade-offs — without notes. Record yourself on your phone. Listen back. Repeat until it flows.
Quiz — reveal the answers
Why is the read path cached but the write path not?
Why 7 characters for the code? What if we run out?
When would 301 be the right choice over 302?
Your cache TTL is 24h. A hot URL suddenly changes target. Users still get the old one. How do you fix it?
One Redis node dies. What happens? Is the system down?
What makes a URL shortener a "read-heavy" system in one sentence?
Next: Lesson 02 — Design a Chat App
WebSockets, message fan-out, presence tracking, message ordering. The second classic question, and where queuing enters the picture.