Software Engineering Wiki

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 #

OperationOrder of magnitude
L1 cache reference1 ns
Main memory reference100 ns
SSD random read100 µs
Datacentre round trip500 µ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.

QuantityReference point
1 request/second sustained2.6 million per month
1,000 rpsA single well-tuned service instance can often do this
99.9% availability43 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.

SignalWhy
Rate, errors, duration (RED)Per service, from the caller’s perspective
Utilisation, saturation, errors (USE)Per resource: CPU, memory, disk, network
Queue depthLeading indicator; latency is the lagging one

Scaling #

ApproachBuysCosts
VerticalSimplicity; no distributed reasoningA ceiling, and a single failure domain
HorizontalHeadroom and redundancyStatelessness, coordination, data partitioning
Read replicasRead capacityReplication lag and stale reads
ShardingWrite capacityCross-shard queries, rebalancing, hot keys
QueueingAbsorbs burstsLatency, ordering, and at-least-once delivery
CachingLatency and loadStaleness 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 #

PatternHow it worksFailure mode
Cache-asideApp reads cache, on miss loads and populatesThundering herd on expiry
Read-throughCache loads from the store itselfSame, hidden inside the cache
Write-throughWrite to cache and store togetherSlower writes, consistent reads
Write-behindWrite to cache, flush asynchronouslyData loss if the cache dies
Refresh-aheadRefresh before expiryWasted 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 second

Three 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 #

LayerSeesCan do
L4 (TCP)Addresses and portsFast, protocol-agnostic, no retries or routing by path
L7 (HTTP)Method, path, headersRouting, retries, rewriting, per-route timeouts, TLS termination
AlgorithmUse
Round robinHomogeneous backends, uniform requests
Least connectionsVariable request duration
Least time / EWMAHeterogeneous backends; best default for HTTP
Consistent hashingCache affinity; minimises reshuffling when a node leaves
Random two choicesNearly 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 #

MechanismPreventsWatch out for
TimeoutUnbounded waitsMust be shorter than the caller’s timeout
Retry with jittered backoffTransient failureRetry storms; only retry idempotent work
Circuit breakerHammering a dead dependencyHalf-open probes need to be cheap
BulkheadOne slow dependency exhausting the poolSizing each pool
Rate limitOverload and abusePer-tenant, not just global
Load sheddingTotal collapseShed cheaply, at the edge, with 429/503
Idempotency keyDuplicate side effects on retryStorage 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 #

QuestionPick
Relationships, transactions, ad-hoc queriesRelational, until proven otherwise
Known access pattern, huge volume, simple keysKey-value or wide-column
Full-text search and rankingA search engine, fed from the store of record
Time series with retention and rollupsA purpose-built TSDB
Events consumed by many independent readersA 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
DecisionGuidance
VersioningIn the path (/v1) — visible in logs, routable, obvious
PaginationCursor, not offset: stable under concurrent writes, and cheap at depth
ErrorsA consistent envelope with a machine-readable code and a human message
Partial failureReport per-item status in bulk endpoints; do not fail the batch
IdempotencyAccept an Idempotency-Key on POST and store the result
ConcurrencyETag plus If-Match, returning 409 on conflict
Rate limitingx-ratelimit-* headers and retry-after on 429
Long operationsReturn 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.

ConcernAnswer
DeliveryAt-least-once in practice; make consumers idempotent
OrderingOnly within a partition or key; design so global order is not needed
Poison messagesDead-letter queue with a retry budget and an alert on depth
BacklogAlert on age of the oldest message, not just depth
Fan-outTopic per event type; one queue per consumer group
Exactly-onceAchievable only as “at-least-once plus deduplication at the sink”

Multi-tenancy #

IsolationModel
Shared everythingTenant column on every row and every query; cheapest, riskiest
Shared database, schema per tenantBetter blast radius, harder migrations
Database per tenantClean isolation, operational overhead grows linearly
Cluster per tenantRegulatory 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:

  1. What is the request rate, the data volume and the growth rate?
  2. Which parts must be strongly consistent, and which may be stale?
  3. What happens when each dependency is slow, then when it is down?
  4. Where is the state, and how is it restored after loss?
  5. What is the blast radius of one bad deploy, one bad tenant, one bad key?
  6. How would an operator detect this failing, and what would they do?
  7. What is the rollback, and has anyone run it?

Last updated 15 September 2026 · Edit this page