02

Scalable System Design Patterns

Core patterns for growing a system from one server to a distributed architecture.

The mental model

Scaling is not a bag of tricks; it is a small, repeatable loop you apply wherever the system hurts. "Scaling" here just means letting a system serve more traffic or data without falling over. Almost every large system is the same handful of moves — route, authenticate, read or write, defer slow work, cache what is hot, split what is too big, copy what must survive failure, and watch all of it — applied in the order the load demands.

The discipline is to reach for these moves only where a real bottleneck appears, not everywhere at once. Over-engineering early is the most common way to make a system both slower to build and harder to operate.

Keep returning to this frame. Each pattern below is one node in that loop made concrete.

The base architecture

Before adding anything clever, start from the simplest shape that can serve real users. A good default has four parts: a load balancer (a component that spreads incoming requests across many servers), a tier of API services (the processes that run your application logic), a database as the durable source of truth, and a queue (a buffer that holds work to be done later) feeding background workers. A cache (a fast in-memory store of recently used values) sits beside the API tier for hot reads.

The rule of thumb: begin with stateless API services behind a load balancer, a durable database for authoritative data, a cache for hot reads, and a queue for slow or retryable background work. Everything later is a response to a measured limit of this base.

Horizontal scaling (stateless services)

When one server can no longer keep up, the cheapest fix is to run more copies of it. This is horizontal scaling — adding machines rather than buying a bigger one (which is vertical scaling). It only works if the servers are stateless, meaning no request depends on data held in the memory of one specific server.

Any state that must persist across requests — sessions, uploads in progress, counters — belongs in shared storage: a database, a cache, or a dedicated session store. Keep the API tier stateless so it can scale horizontally; if session state is needed, store it in a shared store rather than pinning a user to one server.

BenefitCost
Easy to add capacityState must be externalized
Better availability — one dead instance is not fatalMore moving pieces to run
Simple, uniform deploymentNeeds load balancing and health checks

Load balancing

Once several identical instances exist, something must decide which one handles each request. That is the load balancer's job: distribute traffic across healthy instances and route around failed ones. It is the front door that makes many servers look like one endpoint.

A load balancer earns its place through more than round-robin distribution: health checks (periodic probes that mark an instance up or down) let it stop sending traffic to broken instances, connection draining lets in-flight requests finish during a deploy, and edge rate limiting can shed abusive traffic before it reaches your services. The principle: use health checks so failed instances are removed automatically, giving both horizontal scale and failure isolation.

AlgorithmHow it decidesGood forRisk
Round-robinNext instance in rotationSimple, even trafficIgnores actual load
Least connectionsInstance with fewest open connectionsLong-lived connectionsMore runtime tracking
Weighted routingProportions you assignCanary releases, migrationsMisconfiguration sends too much

Caching hot reads

When the same data is read far more often than it changes, reading it from the database every time is wasteful. A cache holds those hot values in fast memory so most reads never touch the database. Good examples are user profiles, product metadata, feed items, configuration, and feature flags — data that is read constantly and updated rarely.

The most common strategy is cache-aside: the application checks the cache first and, on a miss (the value is absent), reads the database and writes the value back into the cache with a TTL (time-to-live — an expiry after which the entry is dropped). The flow in words:

Read key from cache.
On hit, return the value.
On miss, read from the database.
Write the value into the cache with a TTL.
Return the value.
StrategyHow it worksUse when
Cache-asideApp checks cache, falls back to DB on missMost general read-heavy workloads
Write-throughWrite cache and DB together on every writeReads must see writes immediately
Write-behindWrite cache now, flush to DB laterVery high write throughput, weaker durability is acceptable
TTL-basedEntries simply expire after a set timeSlightly stale data is fine

Start with TTL-based expiry because it is simple; if freshness matters, invalidate or update the cache on writes. Two failure modes deserve attention. Caching adds a staleness risk and the perennial problem of cache invalidation. And a hot key — one entry receiving a huge share of traffic — can overload a single cache node.

BenefitCost
Lower read latencyStale data risk
Lower database loadInvalidation is hard to get right
Absorbs traffic spikesHot keys can overload one node

Mitigate hot keys by adding a small in-process cache in front of the shared cache, replicating the hot key across nodes, coalescing duplicate concurrent requests into one database read, jittering TTLs so entries do not all expire together, and pre-warming known-hot items before a spike.

Async processing with queues

Not all work has to finish before the user gets a response. Slow, bursty, or retryable work — sending email, processing video, delivering webhooks, building reports, indexing search, running long jobs — can be handed to a queue and done later by workers (background processes that pull work off the queue). The API acknowledges quickly; the work happens out of band.

Without a queue the client waits for everything, including the slowest step. With a queue the client gets a fast acknowledgement and workers process the job afterward. That shift buys latency and burst absorption at the price of eventual consistency — the result is not ready the instant the request returns.

A durable queue brings a small vocabulary you must design for. Retries with backoff re-attempt failed work after growing delays. A dead-letter queue (DLQ) is a side queue that catches messages which keep failing, so they can be inspected instead of blocking the pipeline. Because most queues guarantee at-least-once delivery — a message may be delivered more than once — workers must be idempotent, meaning processing the same message twice has the same effect as processing it once. The rule: put slow or failure-prone work behind a durable queue so the API returns quickly and workers retry safely; since delivery is at-least-once, workers must be idempotent.

BenefitCost
Better request latencyEventual consistency
Handles traffic burstsNeeds retries and a DLQ
Isolates slow-dependency failuresMore operational surface
Workers scale independentlyDuplicate processing is possible

Database replication

A single database eventually becomes the read bottleneck, and it is also a single point of failure. Replication copies the data to additional nodes: a primary takes all writes, and one or more read replicas receive a stream of those changes and serve reads.

This scales reads and improves availability, and replicas double as warm backups for disaster recovery. The catch is replica lag — a replica may be a moment behind the primary, so a read there can miss a just-committed write. The principle: when reads dominate, add read replicas and keep writes on the primary; anything that needs read-after-write consistency (a user must see their own latest write) should read from the primary.

BenefitCost
Scales read capacityReplica lag yields stale reads
Higher availabilityFailover adds complexity
Backups and disaster recoveryWeaker consistency for replica reads

Sharding and partitioning

When one database can no longer hold the data or absorb the write volume, replication does not help — every replica still holds the whole dataset. Sharding (a form of partitioning) splits the data across independent databases, each owning a slice. Every row lives on exactly one shard, chosen by a shard key — the field that decides which shard a record belongs to.

Shards can be split by range, as above, or by hash of the key so records spread evenly:

shard = hash(user_id) % number_of_shards

The shard key is the most consequential choice, because it fixes which queries stay cheap (those that hit one shard) and which become expensive cross-shard queries (those that must fan out to all shards). Choose the shard key from access patterns, not just the data's shape, and delay sharding until the primary database is genuinely the bottleneck.

Shard keyGood forRisk
user_idData owned by one userCross-user queries get hard
tenant_idB2B tenant isolationA large tenant becomes a hot shard
regionData residency, local latencyUneven traffic per region
timeLogs and eventsThe current window is a hot partition
BenefitCost
More total write capacityCross-shard queries become hard
Larger total datasetRebalancing is complex
Tenant and region isolationA hot shard can still overload one node

Rate limiting and backpressure

A shared system must protect itself from abuse, buggy clients, and noisy tenants that would otherwise starve everyone else. Rate limiting caps how many requests a caller may make in a period. Backpressure is the complementary idea: when downstream capacity is exhausted, deliberately slow or reject new work rather than letting queues grow without bound and collapse.

Limits can apply per user, per tenant, per IP, per API key, per endpoint, or globally across the whole system. Four algorithms cover most needs. The principle: apply per-tenant and global limits so one caller cannot degrade the system for everyone, and when a downstream is overloaded, apply backpressure instead of letting queues grow unbounded.

AlgorithmHow it worksGood for
Token bucketRequests spend tokens that refill at a steady rateAllowing short bursts
Leaky bucketRequests drain at a fixed rate, smoothing outputSteady downstream load
Fixed windowCount requests per clock windowSimple, but bursty at window edges
Sliding windowRolling count over the last intervalMore accurate, more expensive

Idempotency

Networks retry. A client that times out may resend a request; a queue may deliver a message twice. For operations that must not happen twice — charging a card, creating an order, launching a job — this duplication is dangerous. An idempotency key is a unique identifier the client attaches to a request so the server can recognize a repeat and return the original result instead of acting again.

The rule of thumb: because retries can create duplicates, use idempotency keys for any non-idempotent operation such as creating an order or launching a job. This is the client-facing twin of the idempotent worker from the queue section — the same defense applied at the API boundary.

Event-driven architecture

When one business event must trigger several independent reactions, wiring the producer directly to every consumer creates brittle coupling. In an event-driven architecture the producer publishes a domain event — a record that something happened — to an event bus (a channel that fans the event out), and any number of consumers subscribe without the producer knowing they exist.

This decouples producers from consumers and makes adding a new reaction as simple as adding a subscriber. The costs are the ones every asynchronous system pays: harder debugging across hops, eventual consistency, and the need for event schemas and versioning so producers and consumers can evolve. As with queues, consumers must be idempotent and observable. The principle: when many systems must react to the same state change, publish a domain event rather than calling each system directly.

BenefitCost
Loose couplingHarder end-to-end debugging
Add consumers without touching the producerEventual consistency
Scales to many reactorsNeeds event schema and versioning
Natural audit trailOrdering can be tricky

CQRS-lite

Sometimes the way data is written and the way it is read diverge so far that one model serves neither well. CQRS (Command Query Responsibility Segregation) splits them: writes go to a normalized transactional store, and reads come from a separate read model — a denormalized copy, search index, or cache shaped for fast queries — kept up to date from the write side. The "lite" version keeps this deliberately simple and only introduces it when the read cost justifies it.

The read model is updated from write-side events, so it is eventually consistent with the source of truth. That is the core trade: fast, purpose-built reads in exchange for more moving parts and the need to rebuild or backfill the read model when its shape changes. The rule: when read queries become too expensive, build a denormalized read model instead of overloading the write database — and only once the access pattern clearly justifies it.

BenefitCost
Reads optimized independently of writesEventual consistency between the two
Better query performanceMore components to run
Read and write sides scale separatelyNeeds rebuild and backfill paths

Multi-region

Serving users on one continent from a data center on another adds unavoidable latency, and a single region is a single point of failure for the whole product. Multi-region deployment places the system in more than one geographic region. The two main shapes trade simplicity against latency and availability.

In active-passive, one region serves all traffic while a standby region stays replicated and idle, ready to take over on failure. It is simple, but failover takes time and the standby capacity sits mostly unused. In active-active, every region serves traffic and they synchronize with each other — lower latency and higher availability, at the price of conflict resolution when two regions accept writes to the same data at once.

The principle: start single-region unless requirements force otherwise; for disaster recovery, active-passive is simpler; for global low latency, active-active may be needed, but conflict resolution becomes the hard problem.

DesignBenefitCost
Active-passiveSimpler disaster recoveryFailover time, idle standby
Active-activeLower latency, higher availabilityConflict resolution, real complexity
Regional partitioningData residency complianceGlobal queries get hard

Observability

A system you cannot see inside is one you cannot scale with confidence — you would be guessing where the next bottleneck is. Observability is the ability to understand a system's internal state from its outputs, built on three pillars: metrics (numeric time series like latency and error rate), logs (timestamped event records), and traces (the path of one request across services).

Instrument the critical path first: request latency (especially P95/P99, the 95th and 99th percentile latencies that expose the slow tail, not just the average), error rate, saturation, queue depth, cache hit rate, and database latency. Wrap those in dashboards and alerts, and define SLOs (service-level objectives — explicit reliability targets) with error budgets (how much unreliability is acceptable before you stop shipping features and fix stability). Without this, you cannot know where the system is actually bottlenecking.

How to evolve a system by bottleneck

The patterns above are not a menu to order all at once; they are responses to specific limits. The method for growing a system is the same every time: build the simplest thing that works, measure where it breaks, add exactly the pattern that relieves that bottleneck, and repeat. This keeps complexity proportional to real need.

The steps in order: clarify the core use cases and their read/write mix, latency target, and consistency needs; sketch the core API and data model to reveal access patterns; draw the simple architecture; then add scale by bottleneck using the table below; and name the trade-off at each step.

BottleneckAdd
API CPU saturatedMore stateless API instances
Repeated expensive readsA cache
Slow writes in the request pathA queue and workers
Database read loadRead replicas
Database write load or sizePartitioning / sharding
Slow external dependencyAsync processing with retries
Bursty trafficA queue plus rate limiting
Global latencyA CDN or regional deployment
Failure recoveryReplication, backups, failover

Trade-off vocabulary

Every decision above is a trade-off, and naming trade-offs precisely is what turns a design discussion from vague to rigorous. Reach for the sentence "the trade-off is..." at each step — for example, lower latency versus possibly stale reads, or better write throughput versus cross-shard query complexity. The terms below are the shared language for those trades.

TermMeaning
LatencyHow fast a single request gets a response
ThroughputHow many requests or jobs the system handles per unit time
AvailabilityWhether the system stays usable during failures
ConsistencyWhether reads see the latest correct state
DurabilityWhether committed data survives failures
IdempotencyWhether repeating an operation is safe
BackpressureSlowing or rejecting work instead of collapsing
Hot partitionOne shard or key taking too much traffic
FanoutOne event expanding into many writes or messages
Read-after-writeA user reading their own latest write
Eventual consistencyData becoming correct after a propagation delay

Key takeaways

  • Scaling is one loop — route, defer, cache, split, replicate, observe — applied only where a measured bottleneck appears.
  • Keep the API tier stateless so it scales horizontally; push shared state into a database, cache, or session store.
  • Cache hot reads, but plan for staleness, invalidation, and hot keys from the start.
  • Move slow and retryable work to durable queues, and make every worker idempotent because delivery is at-least-once.
  • Replicate to scale reads and survive failure; shard only when one database is truly the limit, and pick the shard key from access patterns.
  • Protect the system with rate limits, backpressure, and idempotency keys before load forces the issue.
  • Reach for event-driven fanout, CQRS-lite read models, and multi-region only when the access pattern or reliability target justifies the added complexity.
  • Instrument the critical path first; a system you cannot observe is one you cannot safely scale.
  • Grow by bottleneck: build the simplest thing that works, measure where it breaks, add the one pattern that relieves it, and name the trade-off.