Practice
System design
Latency and capacity numbers, scaling and caching patterns, load balancing, API and data decisions, and the failure modes each one introduces.
Numbers to reason with #
| Operation | Order of magnitude |
|---|---|
| L1 cache reference | 1 ns |
| Main memory reference | 100 ns |
| SSD random read | 100 µs |
| Datacentre round trip | 500 µs |
| Disk seek (spinning) | 10 ms |
| Sydney to US west coast round trip | ~150 ms |
| Read 1 MB sequentially from memory | ~10 µs |
| Read 1 MB sequentially from SSD | ~200 µs |
Anything crossing a region costs at least the speed of light; no amount of tuning removes it. Design for one round trip per user action, not five.
| Quantity | Reference point |
|---|---|
| 1 request/second sustained | 2.6 million per month |
| 1,000 rps | A single well-tuned service instance can often do this |
| 99.9% availability | 43 minutes of downtime per month |
| 99.99% | 4.3 minutes per month — needs automated failover |
| 1 TB at 1 Gbps | ~2.2 hours to transfer |
Latency, throughput, saturation #
Throughput is work per unit time; latency is time per unit work; they trade against each other through queueing. As utilisation approaches 100%, queueing delay rises without bound — this is why a system at 80% CPU feels fine and the same system at 95% feels broken.
Measure percentiles, never means. An average of 100 ms with a p99 of 4 s means one request in a hundred is unacceptable, and every page composed of 50 calls hits it.
| Signal | Why |
|---|---|
| Rate, errors, duration (RED) | Per service, from the caller’s perspective |
| Utilisation, saturation, errors (USE) | Per resource: CPU, memory, disk, network |
| Queue depth | Leading indicator; latency is the lagging one |
Scaling #
| Approach | Buys | Costs |
|---|---|---|
| Vertical | Simplicity; no distributed reasoning | A ceiling, and a single failure domain |
| Horizontal | Headroom and redundancy | Statelessness, coordination, data partitioning |
| Read replicas | Read capacity | Replication lag and stale reads |
| Sharding | Write capacity | Cross-shard queries, rebalancing, hot keys |
| Queueing | Absorbs bursts | Latency, ordering, and at-least-once delivery |
| Caching | Latency and load | Staleness and invalidation |
Scale the stateless tier first; it is the easy half. Almost every real limit is the database, and the fix is usually removing work rather than adding replicas.
Shard on a key with even distribution and no cross-shard queries in the hot path: user ID or tenant ID, rarely time. Time-based sharding puts every current write on one shard.
Caching #
| Pattern | How it works | Failure mode |
|---|---|---|
| Cache-aside | App reads cache, on miss loads and populates | Thundering herd on expiry |
| Read-through | Cache loads from the store itself | Same, hidden inside the cache |
| Write-through | Write to cache and store together | Slower writes, consistent reads |
| Write-behind | Write to cache, flush asynchronously | Data loss if the cache dies |
| Refresh-ahead | Refresh before expiry | Wasted work on cold keys |
key = "user:v2:7" # version in the key: deploy invalidates without flushing
ttl = 300 + rand(0, 60) # jitter, or every key expires in the same secondThree defences worth building in from the start: jittered TTLs, a single-flight lock so one miss triggers one load, and a negative cache for “not found” so a missing key cannot be used to hammer the database.
Invalidation is the hard part. Prefer short TTLs and versioned keys over event-driven invalidation, which is correct in theory and wrong in the one path nobody updated.
Load balancing #
| Layer | Sees | Can do |
|---|---|---|
| L4 (TCP) | Addresses and ports | Fast, protocol-agnostic, no retries or routing by path |
| L7 (HTTP) | Method, path, headers | Routing, retries, rewriting, per-route timeouts, TLS termination |
| Algorithm | Use |
|---|---|
| Round robin | Homogeneous backends, uniform requests |
| Least connections | Variable request duration |
| Least time / EWMA | Heterogeneous backends; best default for HTTP |
| Consistent hashing | Cache affinity; minimises reshuffling when a node leaves |
| Random two choices | Nearly as good as least-connections, far cheaper to coordinate |
Health checks must exercise the dependency path that matters without cascading: a readiness check that queries the database takes every instance out at once when the database blips. Check liveness shallowly and readiness slightly deeper, and shed load rather than failing health checks under pressure.
Resilience #
| Mechanism | Prevents | Watch out for |
|---|---|---|
| Timeout | Unbounded waits | Must be shorter than the caller’s timeout |
| Retry with jittered backoff | Transient failure | Retry storms; only retry idempotent work |
| Circuit breaker | Hammering a dead dependency | Half-open probes need to be cheap |
| Bulkhead | One slow dependency exhausting the pool | Sizing each pool |
| Rate limit | Overload and abuse | Per-tenant, not just global |
| Load shedding | Total collapse | Shed cheaply, at the edge, with 429/503 |
| Idempotency key | Duplicate side effects on retry | Storage and expiry of the keys |
Timeouts must shrink as you go deeper: if the edge allows 5 s, the service should allow 3 s and the database 1 s. Equal timeouts at every layer mean the whole chain waits for the slowest thing before anybody gives up.
Exponential backoff without jitter synchronises every client into a retry wave. sleep(random(0, min(cap, base * 2^attempt))) is the version that actually works.
Data #
| Question | Pick |
|---|---|
| Relationships, transactions, ad-hoc queries | Relational, until proven otherwise |
| Known access pattern, huge volume, simple keys | Key-value or wide-column |
| Full-text search and ranking | A search engine, fed from the store of record |
| Time series with retention and rollups | A purpose-built TSDB |
| Events consumed by many independent readers | A log (Kafka, Kinesis) |
One store of record; everything else is a derived projection that can be rebuilt. The moment two systems both claim to be authoritative, reconciliation becomes a permanent tax.
Replication is asynchronous unless you paid for it not to be: a read straight after a write can miss it. Route read-after-write to the primary, or carry a version and wait for it.
CAP in practice: during a partition you choose between serving stale data and refusing requests. Decide per endpoint — a product page can be stale, a payment cannot.
APIs #
GET /v1/orders?status=open&limit=50&cursor=eyJ... 200
POST /v1/orders 201 + Location
GET /v1/orders/{id} 200 | 404
PATCH /v1/orders/{id} 200 | 409
DELETE /v1/orders/{id} 204| Decision | Guidance |
|---|---|
| Versioning | In the path (/v1) — visible in logs, routable, obvious |
| Pagination | Cursor, not offset: stable under concurrent writes, and cheap at depth |
| Errors | A consistent envelope with a machine-readable code and a human message |
| Partial failure | Report per-item status in bulk endpoints; do not fail the batch |
| Idempotency | Accept an Idempotency-Key on POST and store the result |
| Concurrency | ETag plus If-Match, returning 409 on conflict |
| Rate limiting | x-ratelimit-* headers and retry-after on 429 |
| Long operations | Return 202 with a status URL rather than holding the connection |
Breaking changes need a new version; additive changes do not. A field that becomes required, an enum that gains a value clients must handle, or a default that changes are all breaking even when the schema still validates.
Asynchronous work #
Queues turn a synchronous dependency into a durable one, at the cost of eventual consistency and a new failure surface.
| Concern | Answer |
|---|---|
| Delivery | At-least-once in practice; make consumers idempotent |
| Ordering | Only within a partition or key; design so global order is not needed |
| Poison messages | Dead-letter queue with a retry budget and an alert on depth |
| Backlog | Alert on age of the oldest message, not just depth |
| Fan-out | Topic per event type; one queue per consumer group |
| Exactly-once | Achievable only as “at-least-once plus deduplication at the sink” |
Multi-tenancy #
| Isolation | Model |
|---|---|
| Shared everything | Tenant column on every row and every query; cheapest, riskiest |
| Shared database, schema per tenant | Better blast radius, harder migrations |
| Database per tenant | Clean isolation, operational overhead grows linearly |
| Cluster per tenant | Regulatory or very large tenants only |
Whatever the model, enforce the tenant boundary in one place — a row-level policy or a repository layer — never by remembering to add WHERE tenant_id = ? in each query.
Noisy neighbours are the default failure: per-tenant quotas and rate limits are not optional once more than one tenant matters.
Reviewing a design #
Ask these in order, and stop when an answer is missing:
- What is the request rate, the data volume and the growth rate?
- Which parts must be strongly consistent, and which may be stale?
- What happens when each dependency is slow, then when it is down?
- Where is the state, and how is it restored after loss?
- What is the blast radius of one bad deploy, one bad tenant, one bad key?
- How would an operator detect this failing, and what would they do?
- What is the rollback, and has anyone run it?