Systems Design
Systems Design

A framework for any design: requirements → estimates → API → data → scale

Lesson · ~35 min
After this unit you can
  • Split requirements into functional and non-functional, and turn an availability target into a downtime budget — lower still across hard dependencies
  • Estimate average and peak requests per second and storage from stated assumptions, showing every step of the arithmetic
  • Derive the API and data model from the requirements, including choosing 301 vs 302 for a redirect and a scheme for minting short codes
  • Find the hot path from the read:write ratio, say what to scale first, and name what the numbers say not to build yet

Read

Simple
1 / 2

Five steps, in an order that feeds itself

Flag

A design discussion usually goes wrong the same way: someone names a database in minute two, and every later decision gets bent to fit it. The fix is an order of work where each step's output is the next step's input:

  1. Requirements — what it must do (functional) and how well (non-functional: latency, availability, durability, how fresh data must be).
  2. Estimates — how much: requests per second on average and at peak, storage over the retention period, the read:write ratio.
  3. API — the operations the requirements name, written as requests and responses.
  4. Data model — the records those operations read and write, keyed the way the hottest operation looks them up.
  5. Scale — only now: which path is hot, what breaks first at the estimated load, and what to add (a cache, replicas, a queue, shards).

A URL shortener, one line per step (the numbers come from the next example):

StepURL shortener
RequirementsCreate a short link for a long URL; /{code} redirects to it; redirects are fast and almost always up
Estimates~40 creates/s and ~4,000 redirects/s on average — 100× more reads than writes
APIPOST /api/links → 201 with the code; GET /{code} → a redirect with a Location header
Data modellinks(code PRIMARY KEY, long_url, created_at, …) — one lookup by code is the whole read path
ScaleThe redirect is the hot path: cache it, add read replicas. 40 writes/s needs nothing special

Look at what the order bought you. By step 5 you know the redirect is the only thing worth optimising, and that sharding the database would solve a problem the numbers say you don't have. (Why a primary-key lookup stays fast as the table grows is the Databases track's indexes unit.)

Medium
1 / 2

The URL shortener, steps 1 and 2 — requirements and estimates

Flag

Functional requirements — what a user can do:

  • Create a short link for a long URL, optionally with an expiry.
  • Open https://sho.rt/{code} and land on the long URL.
  • See how many times a link was clicked.

Non-functional requirements — how well. Turn each adjective into a number someone could check, and agree it before designing:

VagueCheckable
"fast redirects"p99 redirect latency under 50 ms, measured as close to the user as possible
"always up"redirects 99.9% available per month (~43 minutes); creating links can be allowed less
"never lose links"a created link survives the loss of any single machine or disk
"click counts"counts may lag by a minute — they're analytics, not billing

Two of those rows are design decisions in disguise. Splitting availability by path (redirect vs create) lets the hot path be stricter than the rest. And "counts may lag" is permission to take click counting off the redirect's critical path: the redirect can emit an event and return, and something else counts later (the mechanics of handing work to a queue are the HTTP track's Sync vs async APIs unit).

Estimates, every assumption labelled:

Creates:   100M links/month (assumption) / 2,592,000 s          ≈ 39/s on average
Redirects: 100 opens per link created (assumption) × 39/s       ≈ 3,900/s on average
Peak:      3× average (assumption)                               ≈ 12,000 redirects/s
Storage:   ~500 B per link (URL, code, timestamps, index overhead — assumption)
           100M × 12 months × 5 years = 6 × 10⁹ links × 500 B   ≈ 3 TB
Egress:    a redirect is a few hundred bytes → ~1 MB/s at average load

What those numbers say:

  • Reads outnumber writes 100 to 1, so the redirect path is where the effort goes.
  • ~40 writes/s is small for one database primary. Nothing about creating links needs sharding.
  • 3 TB over five years is a disk-size and backup question, not a reason to shard on day one.
  • Bandwidth doesn't matter here — redirects are tiny. (A video service flips this.)

The latency budget. 50 ms at p99 sounds generous until you count hops. Redis's latency guide puts the typical latency of a 1 Gbit/s network at about 200 µs, so a few cache or database round trips inside one region fit easily. Distance doesn't fit: light in optical fibre covers roughly 200 km per millisecond (an order-of-magnitude assumption — about two-thirds of its speed in a vacuum), so New York ↔ London, about 5,600 km apart, is at least ~56 ms round trip before any routing or processing. A 50 ms p99 for users on two continents therefore means serving redirects from more than one region. That's a requirement discovered by arithmetic, before anyone picked a technology.

Complex
1 / 3

301 vs 302 is a caching decision

Flag

The rule is in the HTTP status codes reference cards: RFC 9110 makes 301 and 308 heuristically cacheable, and 302 and 307 not. "Heuristically cacheable" means a browser or shared cache may store the redirect and reuse it without asking you again, even though you sent no Cache-Control (RFC 9111 lets a cache store a response that carries no caching headers at all only when its status code is defined as heuristically cacheable). A 302 or 307 is stored only if its headers allow it: a lifetime (max-age, s-maxage, Expires), or an explicit Cache-Control: public — which, with no lifetime, makes even a 302 heuristically cacheable (RFC 9111 §5.2.2.9). So check the headers your redirect actually goes out with, not just its status.

For a shortener, that rule becomes two product consequences:

  • Clicks disappear. A returning visitor's browser follows its stored 301 and never reaches your server, so the click count undercounts — silently, and most for your most loyal users.
  • Destinations stop being yours to change. If a link is edited or taken down after a phishing report, clients holding a cached 301 keep going to the old target until their cache lets it go. You can purge a CDN; you can't purge a stranger's browser.

A 302 (or 307) with none of those headers isn't stored, so every click comes back through you. That's the trade: you serve every redirect — exactly the load the estimates planned for — in exchange for counting and controlling every redirect. A shortener that never edits links and doesn't sell analytics could choose 301 and let browsers absorb repeat traffic. That's a legitimate answer too, as long as you say what it costs.

Explicit headers override the defaults in both directions: a 302 with Cache-Control: max-age=… (or just public) becomes storable, and a 301 with Cache-Control: no-store must not be stored. Which directive does what, and how a CDN reads s-maxage, is the Next.js track's Response headers: HTTP caching & compression unit. The design-level point to say out loud: the status code is a decision about who may cache, so make it from the requirements (analytics, editability, takedowns) — not from whether "permanent" sounds right.

Quiz

Which of these is a non-functional requirement for a URL shortener?

Flag

Two minutes into a design review, someone asks: "Postgres or DynamoDB?" What's the strongest reply?

Flag

The redirect path targets 99.9% availability per 30-day month. Roughly how much downtime does that allow?

Flag

The estimates say: ~40 link creates/s, ~4,000 redirects/s, ~3 TB over five years. What do you scale first?

Flag

The redirect service, its Postgres and its Redis are each designed for 99.9%, and a failure in any of them fails the redirect. What can the redirect path honestly claim?

Flag

Product wants accurate click counts and the ability to kill a phishing link immediately. Which redirect should the shortener send?

Flag

Short codes come from one counter written in base62 (…aZ3, …aZ4, …aZ5). What's the real cost of that scheme?

Flag

Random 7-character base62 codes; 6 × 10⁹ are already in use out of ≈ 3.5 × 10¹². What determines how often a new insert has to retry?

Flag

Fill in the blank

Turn 99.9% over a 30-day month into a downtime budget, in minutes.

Flag
// 99.9% availability, 30-day month
budget_minutes = (1 - 0.999) * 30 * 24 * 60   // = 

Convert a monthly volume into an average rate. The divisor is the number of seconds in a 30-day month.

Flag
avg_per_second = requests_per_month / 

Write the shortener's redirect so caches don't store it by default and the browser knows where to go.

Flag
HTTP/1.1  Found
: https://example.com/a/very/long/path

Encode a counter value as a base62 short code.

Flag
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

function encode(n: number): string {
  let code = "";
  do {
    code = ALPHABET[n % ] + code;
    n = Math.floor(n / 62);
  } while (n > 0);
  return code;
}

Insert a random code; if it's already taken, return no row instead of raising an error, so the caller can generate another and retry.

Flag
INSERT INTO links (code, long_url) VALUES ($1, $2)
ON CONFLICT (code) DO 
RETURNING code;

Predict the output

// Estimate — assumptions stated, round freely
new_links_per_month = 100_000_000
seconds_per_month   = 30 * 86_400

average_writes_per_second = ?
Flag
// Estimate — requirement: p99 redirect under 50 ms for users in New York AND London
// one region, in New York; distance to London ≈ 5,600 km
// assumption: light in fibre covers ≈ 200 km per millisecond

minimum_round_trip_from_london_ms = ?
Flag
// Estimate — random base62 codes, 6 characters
space         = 62 ** 6          // ≈ 5.68 × 10^10
codes_in_use  = 6_000_000_000    // five years of links

fraction_of_new_inserts_that_collide = ?
Flag

Further reading