SysDesign/Lesson 01 · URL Shortener
FOUNDATIONS
LESSON 01 / 12 ⏱ 2–3 HOURS DIFFICULTY: INTERMEDIATE CONCEPTS: HASH · CACHE · SQL · SCALE

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.

Before we start

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
Step 1 · Requirements

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 questionTypical answerWhy it matters
How many new URLs per day?100 millionSets 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 expireStorage math + cleanup design
Max length of long URL?~2,048 charsStorage per row, TEXT column choice
Custom aliases needed?Nice-to-haveExtra validation + collision handling
Latency target?p95 < 100 msForces cache in front of DB
Analytics needed?Later (async events)Drives 302 choice + queue (Kafka)
Interview skill Write the answers down as you go. When you later say "100M writes/day means ~1,200 writes/second, so one database node is plenty for writes", the interviewer hears structured thinking — that's the Senior signal.
Step 2 · Numbers

Capacity estimation (back of the envelope)

You don't need a calculator. You need round numbers and unit sanity:

MetricCalculationResult
Write QPS100M / 86,400 s ≈ 1,200/sOne DB handles writes easily
Read QPS10B / 86,400 s ≈ 115,000/s (peak ~2×)Need cache + many app servers
Row sizecode + URL + timestamps ≈ 500 bytes
Storage / year100M × 500 B ≈ 50 GB/day ≈ 18 TB/year5 years ≈ 90 TB → archive old rows
Code spacebase62 = 62 chars; 62⁷ ≈ 3.5 trillion7 chars is plenty for years
Bandwidth (reads)115K/s × ~200 B redirect~23 MB/s — trivial for any LB
Common mistake Don't memorize numbers — memorize ratios. The shape of this system is 100 reads for every 1 write. That ratio is why the design is "cache everything, keep the DB as the source of truth."
Step 3 · Contract

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 /Ab3xY9z302 Found
  Location: https://very-long.example.com/blog/2026/...
Detail that impresses Mention validation and error codes unprompted: 400 for invalid URL, 404 for unknown code, 429 when rate-limited, 410 Gone for expired URLs. Interviewers notice complete thinking.
Step 4 · Architecture

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.

WRITE PATH — POST /api/urls
Client
browser / mobile
App Servers (.NET)
validate → encode → insert
SQL Database
PostgreSQL · unique(code)
READ PATH — GET /{code} (100× more traffic)
Client
browser
Load Balancer
round-robin · health checks
Redis Cache
hit → redirect instantly
MISS → fall through ↓
SQL Database
indexed by short_code
ComponentJobNotes for the interview
Load balancerSpread traffic, health checksApp servers are stateless → horizontal scaling is trivial
App servers (.NET)Handle both endpointsStateless: session/state lives in Redis, not in process memory
Redis cacheServe hot redirectsCache-aside, TTL 24–48 h, LRU eviction handles the 80/20 pattern
SQL databaseSource of truthUnique index on short_code; PostgreSQL preferred in modern .NET
Kafka (future)Click analytics eventsAsync — never block the redirect on analytics
Step 5 · The meat

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.
Senior answer "At 100M/day I'd use ID + base62 — no collision handling to maintain. For a smaller system, hash-truncate is simpler and fine." Pick one, justify it, acknowledge the trade-off. That's the whole game.

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);
  • UNIQUE on short_code is 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:

  1. Read GET /{code} → check Redis first.
  2. Hit → return 302 immediately (μs).
  3. Miss → query SQL → store in Redis with TTL → return 302.
  4. TTL of 24–48 h + LRU eviction keeps hot URLs alive automatically (80/20 rule).
Bonus senior points Mention cache stampede: when a hot code expires, 10,000 requests all miss and hit the DB at once. Fixes: per-key lock, or "stale-while-revalidate" (serve the old value, refresh in background).

5.4 — 301 vs 302 (the sneaky senior question)

StatusBehaviorCost / benefit
301 PermanentBrowser caches the redirect; future clicks never hit our serversLess load, great SEO — but no analytics: we only see the first click
302 TemporaryEvery click hits our serversFull 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.
Step 6 · Decisions

The trade-off matrix (memorize the shape, not the words)

DecisionOptionsVerdict
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
Step 7 · Code

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.

Practice

Homework

  1. Custom aliases. Add POST /api/urls { longUrl, alias? }. Handle the 409 Conflict case when the alias is taken. Write the validation rules.
  2. Expiry. Design lazy expiry (check expires_at on read, return 410 Gone) plus a nightly batch job that deletes rows older than X. What index does the batch job need?
  3. 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?
  4. 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?
  5. 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.
Check

Quiz — reveal the answers

Why is the read path cached but the write path not?
Answer: Because the read:write ratio is ~100:1. Caching the writes would add complexity for zero benefit — the write path only needs one DB insert. Optimize where the traffic is.
Why 7 characters for the code? What if we run out?
Answer: 62⁷ ≈ 3.5 trillion codes — decades of headroom at 100M/day. If we run out: add a character (8 chars → ~218 trillion) or reuse expired codes. Increasing length is the standard answer.
When would 301 be the right choice over 302?
Answer: When clicks are immutable and you want maximum cache hits — e.g., redirecting to a permanent asset. The cost: no click analytics, because browsers cache the redirect and stop asking your servers.
Your cache TTL is 24h. A hot URL suddenly changes target. Users still get the old one. How do you fix it?
Answer: Explicit invalidation: when the URL is updated, delete the cache key (or bump a version in the key). Cache-aside + invalidation on write. This is the classic "cache invalidation is hard" trap — knowing the fix is the senior signal.
One Redis node dies. What happens? Is the system down?
Answer: No — cache-aside means a cache miss just falls through to SQL. The system stays up, at higher latency and DB load. This is why cache-aside beats write-through for availability. A cache is an accelerator, never a dependency.
What makes a URL shortener a "read-heavy" system in one sentence?
Answer: Every short URL is created once but followed many times — roughly 100 reads per write — so the design optimizes the read path with caching and horizontal app servers.