System design foundations
What are the building blocks of a typical web system, and how do you reason from requirements to a design that holds up?
System design is a loop: turn requirements into numbers, sketch the simplest architecture from standard parts (load balancer, stateless services, cache, database, queue), find the component that breaks first, and fix only that.
THE MENTAL MODEL
Every design is a pipeline of queues with finite capacity, and the slowest stage decides throughput and tail latency for everything behind it. Start from what the system must do and how well (SLOs for latency, availability and durability), then estimate load with back-of-envelope arithmetic so you know whether you are designing for 50 requests per second or 50,000. Keep the request path stateless so you can add identical copies behind a load balancer; push state into a few places built for it: a database (scaled with read replicas and, much later, partitioning), caches for hot reads, object storage for blobs, and queues so slow or bursty work happens off the request path. Protect the system at the edges with rate limits, timeouts and health checks, and watch it with metrics tied to the SLOs. Then iterate: find the bottleneck under the expected peak, change one thing, and re-estimate. Most real designs fail not because a component was missing but because a number was never computed.
HOW IT FITS TOGETHER
- ClientsBrowsers and apps. Retries with backoff and jitter, timeouts, idempotency keys on writes.
- CDN and edgeTLS termination near users, cached static assets and cacheable API responses, WAF and coarse rate limits.
- Load balancer (L4 / L7)Spreads connections or requests over healthy app instances; drains instances that fail readiness.
- Stateless app instancesAny instance can serve any request; session state lives in a token, cache or database. Scale by adding copies.
- Cache (Redis, Memcached)Hot reads served from memory with TTLs; cache-aside keeps the database authoritative.
- Database primary + replicasPrimary takes writes; async replicas serve reads that tolerate lag. Partition only when one primary cannot keep up.
- Queue and workersEmails, webhooks, image processing and fan-out run asynchronously, retried and scaled on queue depth.
- Functional requirementsCore use cases and APIs. Write what is explicitly out of scope.
- Non-functional requirementsSLOs for availability and latency percentiles, durability, consistency needs, regions, compliance.
- Estimate the loadPeak reads and writes per second, storage growth, bandwidth, read/write ratio and hot keys.
- Sketch the simple versionAPI, data model and one box per component. Usually one app tier and one database.
- Find the bottleneckWalk the peak numbers through each component: CPU, connections, IOPS, memory, network, single partitions.
- Scale that componentCache, replicate, queue, partition or precompute, one change at a time, and state the trade-off it brings.
- Plan for failureWhat happens when each component is slow or down: timeouts, retries, fallbacks, degraded modes.
- Observe and revisitDashboards and alerts on the SLOs; real traffic replaces estimates on the next iteration.
| Read path | Write path | Staleness | If cache is down | Use for | |
|---|---|---|---|---|---|
| Cache-aside (lazy loading) | App checks cache, on miss reads DB and fills cache | App writes DB, then deletes or updates the key | Bounded by TTL plus invalidation races | Reads fall through to DB; protect it from the surge | General-purpose default for read-heavy data |
| Read-through | Cache library or proxy loads from DB on miss | Usually paired with write-through or invalidation | Bounded by TTL | Cache is in the request path, so reads fail too | Uniform loading logic owned by one library |
| Write-through | Cache always holds the latest written value | Write goes to cache and DB in the same operation | Low, but entries written are not always read | Writes block or must bypass the cache | Data read soon after it is written |
| Write-behind (write-back) | Served from cache | Cache acknowledges, flushes to DB asynchronously | DB lags the cache | Unflushed writes are lost | Counters and metrics where loss is acceptable |
| Refresh-ahead | Served from cache; refreshed before expiry | Independent of writes | Up to the refresh interval | Serve stale and keep refreshing in background | Hot keys that must never miss |
KEY TERMS
- SLI, SLO and error budget
- An SLI is a ratio of good events to valid events, such as requests answered successfully under 300 ms. The SLO is the target for that ratio over a window, and 1 minus the SLO is the error budget: the unreliability you may spend on releases and incidents before reliability work takes priority.
- Back-of-envelope estimation
- Daily volume divided by 86,400 gives average per-second load; multiply by a peak factor of 2 to 10. Multiply writes by record size and retention for storage, and responses by size for bandwidth. The goal is the order of magnitude, which decides whether you need one database or a fleet.
- L4 vs L7 load balancing
- An L4 balancer routes TCP or UDP connections by address and port without reading requests, which is fast and protocol-agnostic. An L7 balancer terminates HTTP and routes each request by host, path or header, enabling retries, per-request balancing over long-lived HTTP/2 connections and TLS offload.
- Stateless services and horizontal scaling
- If no request depends on memory in a specific instance, instances are interchangeable: you scale by adding copies and survive losing one. Sticky sessions and in-process caches quietly break that and make deploys and autoscaling lose state.
- Read replicas and partitioning
- Replicas multiply read capacity but not write capacity and serve slightly stale data. When writes or data size outgrow one primary, partition by a key so each shard owns a subset, at the cost of cross-shard queries and transactions.
- Queues for async work
- A queue decouples accepting work from doing it: the request returns quickly, workers process at their own pace, bursts become backlog instead of errors, and failures are retried. The price is eventual completion, idempotent consumers and a dead-letter path.
- Rate limiting and load shedding
- Token buckets cap each client's sustained rate while allowing short bursts, protecting shared capacity from one noisy caller. Load shedding rejects excess work early with 429 or 503 so the requests you do accept still meet their latency SLO.
IN YOUR STACK
TypeScript · Plain arithmetic Client + server
// A photo-sharing feed: 10M daily active users.
const DAU = 10_000_000;
const SECONDS_PER_DAY = 86_400;
const PEAK_FACTOR = 3;
const readsPerSec = (DAU * 20) / SECONDS_PER_DAY; // 20 feed loads/user/day ~ 2,315/s
const writesPerSec = (DAU * 2) / SECONDS_PER_DAY; // 2 posts/user/day ~ 231/s
const peakReads = readsPerSec * PEAK_FACTOR; // ~ 6,900/s
const postMetaBytes = 1_000; // row in the database
const photoBytes = 500_000; // object in blob storage
const metaPerYearGB = (DAU * 2 * postMetaBytes * 365) / 1e9; // ~ 7,300 GB = 7.3 TB
const photosPerYearTB = (DAU * 2 * photoBytes * 365) / 1e12; // ~ 3,650 TB = 3.65 PB
const feedResponseBytes = 20_000; // JSON for one page of feed
const peakEgressMbps = (peakReads * feedResponseBytes * 8) / 1e6; // ~ 1,100 Mbit/s
// Conclusions the numbers force:
// - 231 writes/s fits one Postgres primary; 6,900 reads/s wants a cache and replicas.
// - Photos never go in the database: object storage plus a CDN.
// - 7.3 TB/year of metadata is a planned partitioning or archiving decision, not an emergency.
console.table({ readsPerSec, writesPerSec, peakReads, metaPerYearGB, photosPerYearTB, peakEgressMbps });- Round aggressively: a day is about 100,000 seconds, so 1M requests per day is roughly 10 per second on average.
- Peak factor, read/write ratio and object size dominate the result; state them as assumptions so they can be challenged.
- Multiply storage by the replication factor and backups before comparing it with disk sizes.
TypeScript · ioredis + node-postgres Server
import Redis from "ioredis";
import { Pool } from "pg";
const redis = new Redis(process.env.REDIS_URL!);
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const TTL_SECONDS = 300;
type Product = { id: string; name: string; priceCents: number };
export async function getProduct(id: string): Promise<Product | null> {
const key = `product:v1:${id}`;
const cached = await redis.get(key).catch(() => null); // cache down: fall through
if (cached) return JSON.parse(cached) as Product;
const { rows } = await db.query<Product>(
'SELECT id, name, price_cents AS "priceCents" FROM products WHERE id = $1', [id]);
const product = rows[0] ?? null;
const ttl = product ? TTL_SECONDS : 30; // short TTL for negative results
await redis.set(key, JSON.stringify(product), "EX", ttl).catch(() => {});
return product;
}
export async function updatePrice(id: string, priceCents: number) {
await db.query("UPDATE products SET price_cents = $1 WHERE id = $2", [priceCents, id]);
await redis.del(`product:v1:${id}`); // invalidate after the commit
}- Delete rather than overwrite on write: a slower concurrent reader can still repopulate a stale value, so the TTL is the backstop.
- Negative caching stops repeated lookups of missing ids from hammering the database.
- A hot key expiring under load triggers a stampede; add request coalescing or early refresh for those keys.
TypeScript · In-process Server
class TokenBucket {
private tokens: number;
private last = Date.now();
constructor(private capacity: number, private refillPerSec: number) {
this.tokens = capacity;
}
tryTake(cost = 1): boolean {
const now = Date.now();
this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.refillPerSec);
this.last = now;
if (this.tokens < cost) return false;
this.tokens -= cost;
return true;
}
}
// Burst of 20, sustained 5 requests/second per API key.
const buckets = new Map<string, TokenBucket>();
export function allow(apiKey: string): boolean {
let bucket = buckets.get(apiKey);
if (!bucket) buckets.set(apiKey, (bucket = new TokenBucket(20, 5)));
return bucket.tryTake(); // false -> respond 429 with Retry-After
}- Capacity sets the allowed burst and refill rate the sustained limit; the bucket refills lazily on each call, so no timers are needed.
- This limit is per process: with N instances the effective limit is N times higher. Use a shared store (Redis with a Lua script) or the gateway for global limits.
- Evict idle buckets, or the map grows with every key ever seen.
nginx · nginx (L7 load balancer) Server
upstream app {
least_conn; # fewest active connections wins
server 10.0.1.10:3000 max_fails=3 fail_timeout=10s;
server 10.0.1.11:3000 max_fails=3 fail_timeout=10s;
server 10.0.1.12:3000 max_fails=3 fail_timeout=10s;
keepalive 32; # idle upstream connections per worker
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_pass http://app;
proxy_http_version 1.1; # needed for upstream keepalive
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 2s;
proxy_read_timeout 15s;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
}
}- Open-source nginx only health-checks passively (max_fails within fail_timeout); active health_check probes are an NGINX Plus feature.
- proxy_next_upstream never resends a POST, PATCH or LOCK that already reached an upstream unless you add non_idempotent; keep it that way unless the endpoint is idempotent.
- For a cloud L4 balancer, the equivalent knobs are the target group's health check path, thresholds and deregistration delay.
TypeScript · Node.js http + pg Server
import { createServer } from "node:http";
import { Pool } from "pg";
const db = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
let draining = false;
async function dbReachable(): Promise<boolean> {
const timeout = new Promise<boolean>((resolve) => setTimeout(resolve, 500, false));
const ping = db.query("SELECT 1").then(() => true, () => false);
return Promise.race([ping, timeout]);
}
const server = createServer(async (req, res) => {
if (req.url === "/livez") return res.writeHead(200).end("ok"); // process alive, no dependency checks
if (req.url === "/readyz") {
const ready = !draining && (await dbReachable());
return res.writeHead(ready ? 200 : 503).end(ready ? "ready" : "not ready");
}
res.writeHead(404).end();
});
server.listen(3000);
process.on("SIGTERM", () => {
draining = true; // fail readiness so the LB stops sending traffic
setTimeout(() => server.close(() => process.exit(0)), 10_000);
});- Liveness restarts the container; checking the database there turns a database blip into a fleet-wide restart loop.
- Readiness removes the instance from rotation without restarting it, which is the right reaction to a missing dependency or to shutdown.
- The drain delay must exceed the time the load balancer takes to notice failed readiness, or in-flight requests are cut off.
TypeScript · Plain arithmetic Server
const SLO = 0.999; // 99.9% of requests succeed
const WINDOW_DAYS = 30;
const budget = 1 - SLO; // 0.1% of requests may fail
// Time-based view: minutes of total outage the budget allows.
const budgetMinutes = WINDOW_DAYS * 24 * 60 * budget; // 43.2 minutes
// Request-based view, which is what most SLIs count.
const expectedRequests = 3_000_000;
const allowedFailures = expectedRequests * budget; // 3,000 failed requests
// Burn rate: how fast the current error rate spends the budget.
// 1 means the budget runs out exactly at the end of the window.
function burnRate(errorRatio: number): number {
return errorRatio / budget;
}
// Page when the last hour burned at >= 14.4x (2% of a 30-day budget in 1 h)
// and the last 5 minutes confirm it is still happening.
function shouldPage(errorRatio1h: number, errorRatio5m: number): boolean {
return burnRate(errorRatio1h) >= 14.4 && burnRate(errorRatio5m) >= 14.4;
}
console.log({ budgetMinutes, allowedFailures, page: shouldPage(0.02, 0.03) });- Each extra nine divides the budget by ten: 99.99% over 30 days is about 4.3 minutes, which rules out manual incident response alone.
- The multiwindow pair (1 h and 5 min) pages on fast burns but stops paging soon after the problem ends.
- A dependency with a 99.9% SLO cannot support a service promising 99.99% unless calls to it are redundant or optional.
WHEN TO REACH FOR WHAT
Cache-aside
Use when Read-heavy data that tolerates seconds of staleness and must survive the cache being unavailable.
Avoid when Every read must see the latest write, or the data is rarely re-read so hit rates stay low.
Read replicas
Use when Reads dominate, queries are already indexed, and the reading paths can tolerate replication lag.
Avoid when Writes are the bottleneck, or a user must immediately read what they just wrote without routing to the primary.
Queue and workers
Use when Work is slow, bursty, retryable or does not need to finish before responding: emails, webhooks, media processing, fan-out.
Avoid when The caller needs the result in the same request, or you cannot make consumers idempotent.
Partitioning / sharding
Use when Write throughput or data size exceeds what one well-tuned primary can handle and the data has a key most queries include.
Avoid when Replicas, caching, archiving or a bigger instance would buy years; resharding and cross-shard queries are a permanent cost.
L7 load balancer
Use when HTTP traffic that needs path or host routing, per-request balancing over HTTP/2, retries, TLS termination or header-based canaries.
Avoid when Non-HTTP protocols or extreme throughput where an L4 balancer's lower overhead and pass-through TLS matter.
CDN
Use when Static assets, media and cacheable API responses served to users far from your origin.
Avoid when Responses are personalised and uncacheable, unless you only want TLS termination and DDoS protection at the edge.
WHERE IT BITES
- Designing before estimating: adding sharding, Kafka and microservices for a load one Postgres instance would serve comfortably.
- Stating averages instead of percentiles; p99 latency and peak-hour load are what break SLOs.
- A cache without a plan for cold starts and hot-key expiry, so the first deploy or a popular key stampedes the database.
- Liveness probes that check the database, turning a dependency outage into every instance restarting at once.
- Retries at every layer with no budget, multiplying load during exactly the incident they were meant to absorb.
- Reading from replicas on paths where the user just wrote, producing read-your-writes bugs that look like lost data.
CLOSE THE AI. EXPLAIN THIS.
A service gets 50M requests a day with a 99.9% monthly SLO. Estimate peak QPS, the error budget in failed requests and in minutes, and name the first two components you would add as reads grow 10x.WHEN IT BREAKS IN PRODUCTION
SOURCES
- Google SRE book: Embracing Risk (opens in new tab)Checked
- Google SRE workbook: Alerting on SLOs (opens in new tab)Checked
- Google SRE book: Load Balancing in the Datacenter (opens in new tab)Checked
- Amazon Builders' Library: Caching challenges and strategies (opens in new tab)Checked
- nginx: ngx_http_upstream_module (opens in new tab)Checked
- Kubernetes: Liveness, readiness and startup probes (opens in new tab)Checked
Explainer reviewed