Scalability Patterns and Techniques Questions
Scaling a system to handle growth in traffic and data: horizontal versus vertical scaling, statelessness, sharding and partitioning strategies, read replicas, and connection pooling. Covers capacity estimation, identifying bottlenecks, and the tradeoffs each scaling axis introduces. The general toolkit for taking a design from thousands to millions of users.
Explain vertical scaling (scale up) versus horizontal scaling (scale out). List three advantages and three disadvantages of each. Then describe the concrete signals or thresholds (CPU, memory, disk, latency) you would monitor to decide that vertical scaling is no longer sufficient and horizontal scaling is needed for a service.
Sample Answer
Direct answer
Vertical scaling (scale up) gives one machine more resources: a bigger CPU, more RAM, faster disks. Horizontal scaling (scale out) adds more machines and spreads load across them. Vertical scaling is the faster first move because it needs no application changes; horizontal scaling is the one that actually removes a ceiling, because a single machine's capacity is always finite no matter how large you buy.
Structured elaboration
Vertical scaling: advantages
- Simplicity: resizing an instance or VM (virtual machine) usually requires no code or architecture change.
- Lower operational surface: one system to patch, back up, and monitor instead of a fleet.
- No distributed-systems tax: no partitioning, no cross-node consistency, no coordination overhead, so single-threaded or tightly-coupled workloads keep their natural performance profile.
Vertical scaling: disadvantages
- Hard ceiling: even the largest cloud instance sizes (high-memory or high-CPU tiers) top out, and that ceiling arrives faster than most teams expect.
- Single point of failure: one node down means the service is down, unless it is paired with a passive standby (a high-availability concern, not a scaling one).
- Non-linear cost: the largest instance tiers carry a steep price premium per unit of CPU/RAM versus a few mid-tier instances doing the same aggregate work.
Horizontal scaling: advantages
- No hard ceiling: capacity grows by adding nodes, which is why it is the pattern behind "web-scale" systems.
- Failure isolation: losing one node out of many degrades capacity slightly rather than taking the service down.
- Elastic cost matching: nodes can be added and removed to track demand, so spend tracks load instead of being sized for peak year-round.
Horizontal scaling: disadvantages
- Requires statelessness or externalized state: a node must be replaceable, which usually means a rewrite if the service was built assuming local state.
- Coordination overhead: partitioning, request routing, and (for data) replication or sharding all add moving parts that a single node never needed.
- Operational complexity: more instances to deploy, patch, and observe, plus the need for a load-distribution layer in front of them (a load-balancing concern, out of scope here, but worth naming as the piece that makes horizontal scaling actually work end to end).
Signals that vertical scaling has run out of road
| Signal | Threshold to watch | Why it matters |
|---|---|---|
| Sustained CPU utilization | Consistently above roughly 70-80% during normal peak, not just brief spikes | Headroom for traffic growth and failover capacity is gone |
| Memory pressure | Sustained high usage with frequent garbage-collection pauses, swapping, or out-of-memory events | The next vertical step is a discrete, expensive jump, and swapping degrades latency badly |
| Disk I/O | High utilization or growing queue depth with rising I/O wait | The disk, not the CPU, has become the bottleneck, and disk throughput on a single node caps out |
| 95th/99th-percentile (p95/p99) latency | Rising tail latency and service-level objective (SLO) breaches under load even after a resize | The vertical lever has already been pulled and stopped helping |
| Cost trajectory | Each further resize costs disproportionately more per unit of added capacity | You are paying the non-linear premium described above with no ceiling relief |
| Availability requirement | Any requirement to survive a single-node failure without downtime | Vertical scaling cannot provide this by itself; only redundancy (horizontal) can |
Cloud-specific version of this decision. The same signals drive the same move on every major cloud, just through different primitives: on AWS you resize the EC2 instance type first, then hand scaling over to an Auto Scaling Group (ASG) that adds instances instead of resizing further; on Azure the equivalent fleet-level primitive is a VM Scale Set; on GCP it is a Managed Instance Group. All three exist because the same lesson applies everywhere: resizing is the cheap first lever, and a policy-driven fleet is the lever that removes the ceiling.
The often-missed factor: licensing. For commercial database or middleware software billed per-core or per-instance, vertical scaling can look artificially attractive on infrastructure cost while licensing cost scales the same way (or worse) as horizontal scaling would, once you account for per-node license fees across a fleet. Model total cost of ownership, not just the compute bill, before committing to either path.
Worked example
A checkout service starts on one 4 vCPU / 16 GB instance. Traffic doubles over two quarters. The team resizes to 8 vCPU / 32 GB (still vertical), which buys headroom for a while. CPU utilization is now steady at 78% during business hours and p99 latency has grown from 220 ms to 410 ms even after the resize, with no code regression identified. Disk and memory are not saturated; only CPU and tail latency are trending against the thresholds above. That combination, a saturated resource plus a latency SLO breach that resizing no longer fixes, is the signal to stop resizing and horizontally scale: put the service behind a fleet (ASG-equivalent) of smaller instances instead of chasing a bigger single one.
In practice, most teams do not choose purely one or the other. A common hybrid: keep the primary datastore vertically scaled as far as practical (since horizontally scaling stateful stores is the harder, sharding-level problem: splitting the data itself across nodes means picking a partition key, routing each query to the node that owns the relevant data, and rebalancing data when nodes are added or removed, none of which a stateless tier ever has to do), while horizontally scaling the stateless application tier in front of it, because the application tier is the cheaper piece to make replaceable first.
Trade-offs & pitfalls
- Resizing repeatedly without a plan for the ceiling is a common trap: teams keep buying the next instance size up until they hit the largest tier available, at which point the horizontal rewrite happens under emergency pressure instead of as a planned migration.
- Horizontal scaling only pays off if the service was made stateless or its state externalized first; bolting a load balancer in front of a stateful service without that groundwork just distributes the same single point of failure.
- Watch resource signals together, not in isolation: CPU can look fine while disk I/O or memory is the real ceiling, and treating the wrong resource as the bottleneck leads to buying the wrong upgrade.
- Cost is not a tie-breaker in only one direction: nonlinear pricing at the top of the vertical tier can push toward horizontal even before a technical ceiling is hit, and licensing cost can push the other way.
You need to shard a user-profile service that is projected to grow from 10 million to 1 billion users. Walk through your shard-key selection criteria, the migration approach from a single shard to many, and how you would rebalance shards with minimal downtime. Compare the trade-offs of consistent-hashing and range-based sharding for this workload.
Sample Answer
Direct answer
For a user-profile service growing from 10 million to 1 billion users, I would shard on a hashed user ID for even distribution, migrate via a dual-write-then-cutover process driven by a routing layer (not a hard-coded formula the application computes itself), and rebalance by moving individual partitions with change-data-capture (CDC) to keep the source and destination in sync until a near-instant cutover. Consistent hashing wins for this workload because access is almost always by single user ID with no need for range scans, and its whole advantage is minimizing data movement when shards are added.
Structured elaboration
Shard-key selection criteria
- Cardinality and uniform distribution: the key must have enough distinct values, and typical access patterns must spread evenly across them. A hashed user ID satisfies this; a raw signup-timestamp-derived ID does not, because recent users cluster on a few shards while old ones sit cold.
- Stability: the key must never change after assignment.
user_idqualifies;emailorusernamedo not, since a rename would require moving the row. - Query locality: does the workload need range scans or joins across users? For a profile service, almost all reads and writes are single-user lookups, so locality by an ordered range is not needed and a hash-based spread is safe.
- Hot-key exposure: a small number of accounts (very active or high-visibility profiles) can dominate traffic on their shard regardless of key choice; the design needs an explicit way to detect and mitigate that (traffic-based alerting plus optional per-key overrides), not an assumption that hashing alone prevents hotspots.
- Geographic skew: if the user base concentrates in specific regions rather than distributing evenly worldwide, a pure hash on user ID can still produce uneven infrastructure load even though key distribution is even, because a shard's users may all be far from the datacenter serving them, or a region's traffic may spike in a way that maps to a subset of shards. This is a distinct problem from key skew: it is about where a shard's traffic originates and lands, not how many keys it owns. The mitigation is to let shard placement (which datacenter/region a shard physically lives in) be a separate decision from the shard key itself.
Routing design: algorithmic versus lookup-service-based
A pure algorithmic router (client computes hash(user_id) mod N or applies consistent hashing directly) is simple and needs no extra network hop, but it hard-codes the shard topology into every client, which makes it painful to move individual users off a hot shard or to change placement for geographic reasons.
The alternative, which is what I would use at this scale, is a shard-map-management routing layer: a small, highly available service that owns the authoritative mapping of user_id → shard, so clients ask the routing layer (or a cached copy of its map) rather than computing the answer themselves. This costs an extra lookup (mitigated with aggressive client-side caching of the map, invalidated on change) but buys the ability to move individual users, override placement for geographic locality, and steer around a hot shard without a client rollout. At the higher end of this growth curve, once the user base is well past a billion (some teams see this discussion framed around roughly two billion users), the routing layer typically also needs sticky routing: caching a client's resolved shard for the duration of a session so repeated requests for the same user do not re-hit the routing layer on every call, trading a small staleness window (the cache might briefly point at a user's old shard mid-migration) for materially lower routing-layer load.
Migration approach: single shard to many
- Stand up the routing layer first, initially mapping 100% of users to the single existing database, so all future migration is just a mapping change.
- Provision the target shards and, for each cohort of users being moved, start a backfill copy from the source into the destination shard.
- Enable dual-write for that cohort: once backfill completes for a user, writes go to both source and destination while reads still come from source.
- Verify the destination has caught up (row counts, checksums, or a CDC-lag check near zero), then flip the routing-layer entry for that cohort to the destination and stop dual-writing.
- Retire the source copy for that cohort once a safety window has passed with no fallback reads observed.
Rebalancing with minimal downtime
flowchart LR
A[Mark partition as migrating in routing layer] --> B[Async bulk copy source to destination]
B --> C[CDC stream captures deltas during copy]
C --> D[Apply captured deltas to destination]
D --> E{Destination caught up?}
E -- No --> C
E -- Yes --> F[Brief write pause, apply final delta]
F --> G[Atomically flip routing-layer entry]
G --> H[Old source marked stale, retired after safety window]
The only downtime is the brief pause in step F while the last few milliseconds of writes replay, typically small enough to be invisible to users; everything else happens while the partition continues serving live traffic from its current location.
Consistent hashing versus range-based sharding, for this workload
Consistent hashing places nodes and keys as points on a circular hash space (the ring); virtual nodes give each physical node many small points on that ring instead of just one, so a topology change only reassigns the affected points.
| Dimension | Consistent hashing (hashed user_id) | Range-based (ordered user_id or signup order) |
|---|---|---|
| Distribution | Even, assuming a reasonable hash function | Skewed toward recently created ranges unless actively rebalanced |
| Rebalancing cost when adding a shard | Low: only keys near the new node's position on the ring move (with virtual nodes smoothing this further) | High: contiguous ranges must be split and large chunks of data physically move |
| Range queries ("all users created this week") | Not supported efficiently; scattered across shards | Efficient; the query touches few, contiguous shards |
| Hot-shard mitigation | Needs explicit hot-key handling since hashing does not fix a single overloaded key | Naturally exposed to hotspots on the newest range unless split proactively |
| Fit for this workload | Strong fit: access is single-user lookups, no range-scan requirement | Weak fit: pays a locality benefit this workload does not need, at the cost of harder rebalancing |
Given a read-heavy, single-user-lookup access pattern with no meaningful range-query requirement, consistent hashing on a hashed user_id, fronted by a shard-map-management routing layer rather than pure client-side computation, is the right choice: it minimizes data movement as the shard count grows from a handful of shards at 10 million users to however many are needed at 1 billion, and the routing layer absorbs the geographic-skew and hot-key concerns that hashing alone does not solve.
Worked example
Starting state: one database serving 10 million users. The trigger to shard is a saturation signal, not a fixed user count: sustained high CPU/IOPS (input/output operations per second) on the primary (concretely: sustained CPU utilization above roughly 70-80%, or IOPS approaching the storage volume's provisioned ceiling). When that signal fires, the team provisions an initial set of shards behind the routing layer, sized so projected per-shard load sits comfortably under those same saturation thresholds, and migrates user cohorts onto them following the steps above. As an illustrative planning input rather than a measured fact, assume a single shard can comfortably sustain roughly 2 million active user profiles' worth of steady-state read/write traffic before crossing that same 70-80%-CPU / near-IOPS-ceiling threshold: that puts the initial split at 10,000,000/2,000,000=5 shards when the 10-million-user primary first hits saturation. From then on, shards are added incrementally whenever the routing layer's per-shard load metrics approach saturation again, all the way to whatever shard count is needed at 1 billion users, which at that same illustrative 2-million-users-per-shard ceiling works out to 1,000,000,000/2,000,000=500 shards. Because the shard key is a hash and the routing layer decouples the shard count from the shard-key formula, each incremental addition only requires migrating the specific user ranges assigned to the new shard, not a full re-partition of the dataset.
Trade-offs & pitfalls
- A pure algorithmic router is simpler to build first but becomes the bottleneck to fix later: teams that skip the routing layer to save one network hop early usually end up building it anyway once they need to move a single hot user off a shard without a client deploy.
- Sticky routing at very large scale trades a small staleness window for routing-layer relief; that staleness window must be bounded and understood, or a mid-migration read can silently hit stale data.
- Consistent hashing does not, by itself, solve a single extremely hot key (a celebrity account); that requires a separate detection-and-override mechanism layered on top, not a property of the hashing scheme.
- Geographic skew and key skew are easy to conflate: even key distribution can still coexist with badly distributed physical load if shard placement ignores where traffic originates.
Compare consistent hashing and range-based partitioning for a large-scale datastore where complex queries and joins across ranges are common. Explain the pros and cons of each for query locality, rebalancing, and ease of scaling, then propose a hybrid partitioning approach that supports complex queries without creating hotspots.
Sample Answer
Direct answer
Consistent hashing gives even load distribution and cheap rebalancing but destroys query locality, forcing every range scan or join into a scatter-gather across many nodes; range-based partitioning gives excellent locality for exactly those queries but creates hotspot risk on skewed keys and makes rebalancing expensive. For a datastore that genuinely needs both complex range queries and elastic scaling, the answer is a hybrid: partition coarsely by range on an attribute that preserves most query locality, then hash within each coarse range to spread load and avoid hotspots inside it.
Structured elaboration
What consistent hashing actually is, before comparing properties. Consistent hashing places both data keys and node identifiers as points on a circular numeric range, called a ring, typically by hashing each one with the same hash function. Each key is then owned by whichever node's point is the first one reached going clockwise from the key's own point on the ring. That single mechanism is what produces both properties compared below: rebalancing is cheap because adding or removing one node only reassigns the keys between it and its ring neighbors, not the whole dataset the way a plain modulo hash (hash(key) mod N) would when N changes; and locality is poor because two keys that are numerically close in the original ID space, like adjacent order IDs, get hashed to essentially random, unrelated points on the ring, so there is no guarantee they land near each other, or on the same node, at all.
Consistent hashing
- Query locality: poor. Related keys are scattered pseudo-randomly across nodes by design, so a range scan or a join across a key range has to fan out to many (potentially all) nodes and merge results, a scatter-gather pattern that is expensive and has a tail-latency cost equal to its slowest participant.
- Rebalancing: cheap. Adding or removing a node (especially with virtual nodes) only moves a small, bounded slice of the keyspace.
- Scaling: straightforward; new capacity absorbs a proportional share of load automatically.
Range-based partitioning
- Query locality: strong. Keys that are close in sort order live on the same node, so range scans and joins over a contiguous key range are single- or few-node operations, and storage engines benefit from sequential I/O and better compression on sorted data.
- Rebalancing: expensive. A skewed key distribution concentrates load on one range, and fixing it means splitting or merging large contiguous chunks of data, a much bigger operation than moving a handful of hash buckets.
- Scaling: harder to automate; adding capacity typically requires a deliberate split-planning step rather than capacity absorbing load on its own.
Hybrid approach
- Choose a coarse partitioning attribute that captures most of the locality your queries need (a time window, a tenant id range, a geographic region) and range-partition on it. This keeps queries that filter or join on that attribute confined to a small number of coarse partitions.
- Within each coarse range, sub-partition by consistent hashing (or a hash of a secondary key) into several sub-shards spread across physical nodes. This is what prevents a single popular coarse range from becoming a hotspot: instead of one range living on one node, it's spread across the sub-shard set.
- Maintain a lightweight metadata/routing layer mapping coarse range to its set of sub-shard endpoints, so the query planner knows which sub-shards a given range maps to without a full scatter.
- Handle skew with adaptive split/merge scoped to the coarse range: if one coarse range's sub-shards are overloaded, split that range's sub-shard count, an operation that only touches that range's data, not the whole dataset.
flowchart TD
Q[Incoming query: range filter] --> P[Router: which coarse ranges match?]
P --> R1[Coarse range 1]
P --> R2[Coarse range 2]
R1 --> H1[Hash sub-shard a]
R1 --> H2[Hash sub-shard b]
R2 --> H3[Hash sub-shard a]
R2 --> H4[Hash sub-shard b]
H1 --> M[Merge results]
H2 --> M
H3 --> M
H4 --> M
This bounds scatter-gather to the coarse ranges a query actually overlaps, times the sub-shard fan-out within each, instead of every shard in the cluster.
Worked example
A metadata/search index over 1 billion objects needs to support both point/range lookups by ingestion time and elastic scaling as volume grows. The team range-partitions coarsely by ingestion week, giving roughly 100 coarse ranges for two years of retained data (a stated design choice, not a derived figure), and hash-partitions each coarse range into 50 sub-shards to spread load evenly within the week, giving 100×50=5,000 total shards across the cluster. A typical query filtering on a 3-week window touches only the 3 matching coarse ranges, fanning out to 3×50=150 sub-shards, or 150/5,000=3% of the cluster, instead of a full scatter-gather across all 5,000 shards that pure consistent hashing on object id would require for the same query. A query that needs a single object by id, with no time filter, still does a targeted hash lookup within whichever coarse range the object's timestamp maps to, so point lookups stay single- or few-shard regardless of the hybrid layout.
Trade-offs & pitfalls
- The hybrid scheme adds a real second layer of metadata and routing logic (which coarse ranges exist, which sub-shards belong to each) that a pure hash or pure range scheme doesn't need; that complexity has to be justified by an actual mixed workload of range queries plus elastic-scaling needs, not adopted by default.
- Choosing the coarse partitioning attribute poorly (one that doesn't align with how queries actually filter) gives you the rebalancing cost of range partitioning without recovering the locality benefit that was the whole point of choosing it.
- A coarse range that itself becomes hot (a recent, actively-written time window, for instance) still needs the adaptive split/merge step; sub-sharding by hash inside it helps distribute load but doesn't eliminate the need to watch for a coarse range outgrowing its allocated sub-shard count.
- Cross-coarse-range joins (joining two objects that fall in different weeks, say) are not free just because the hybrid scheme handles range queries well; that join still fans out across whichever coarse ranges and sub-shards are involved, so it's worth being explicit about which query shapes the hybrid design is actually optimizing for.
Explain backpressure in a distributed system and why it matters for reliability. What mechanisms would you use to implement it between services, such as request quotas, flow control in a messaging system, or reactive streams, and how do they prevent cascading failure?
Sample Answer
Direct answer
Backpressure is a flow-control mechanism where an overloaded consumer signals upstream producers to slow down, so the system stays within its real processing capacity instead of silently queuing work until it runs out of memory or falls over. It matters for reliability because, without it, a slow or overwhelmed component doesn't fail cleanly; it builds an unbounded backlog that eventually causes a resource exhaustion failure, which can then cascade into components that depend on it.
Structured elaboration
The core loop: a consumer reports its available capacity (a credit, a token, an explicit "slow down" signal) back to whatever is sending it work, and the producer honors that signal by slowing down, buffering locally, or rejecting new work rather than forcing it through.
Backpressure versus rate limiting. These are often confused but apply at different points in the request path:
| Backpressure | Rate limiting | |
|---|---|---|
| Who it protects | The receiving component itself, based on its own real-time capacity | The service as a whole, from any single client consuming more than its fair share |
| Where it applies | Internally, between cooperating components (a queue and its consumer, two microservices) | At the edge or ingress, against external or untrusted clients |
| Signal basis | Actual, live capacity (queue depth, in-flight work) | A pre-set policy (N requests per minute), regardless of current internal load |
They compose well together: rate limiting caps what's allowed in at the edge; backpressure handles what happens internally once accepted work outpaces a specific consumer's real capacity.
Concrete mechanisms:
- Reactive Streams (an interface pattern used by libraries like Project Reactor and RxJava): the consumer explicitly calls
request(n)to say how many items it can accept next, rather than the producer pushing an unbounded stream. - TCP windowing (transport layer): a TCP receiver advertises a receive window, the amount of unacknowledged data it's willing to hold, and a sender must stop once that window fills; this is backpressure operating below the application entirely, and it's why a slow reader can stall a writer even with no application-level queue involved.
- gRPC / HTTP/2 flow control: stream-level flow-control windows, multiplexed over one TCP connection, mean a client cannot outrun what the server has said it can currently accept; this is a separate, application-layer analog of the same TCP-level idea, not the same mechanism.
- Kafka consumer-side flow control: a consumer can
pause()/resume()specific partitions, andmax.poll.recordsbounds how many records a single poll returns, both of which let a consumer throttle its own intake rate. - RabbitMQ consumer prefetch: limiting unacknowledged messages delivered to a consumer at once prevents one slow consumer from being handed more work than it can hold.
- Bounded queues with a blocking or rejecting producer: the simplest application-layer mechanism; the queue has a fixed capacity, and a full queue either blocks the producer (creating backpressure) or rejects new work outright (a flow-control protocol decision that trades data loss for keeping the consumer alive).
Worked example
Consider a Go-style worker pool: a hot request-handling path writes work items into a fixed-size in-memory channel that a pool of workers reads from. Under normal load, the channel rarely fills and writes return immediately. Under a sustained spike, the channel fills up. At that point, without an explicit flow-control decision, the sending goroutine (the request handler) blocks on the write, which means the original HTTP request handling itself now blocks, which then makes that server's own request queue back up, which then causes its own health checks or upstream timeouts to start failing, all originating from one bounded channel filling up. This is backpressure working exactly as designed, propagating a real capacity signal backward, but only if the layer receiving that backpressure (the request handler here) has a bounded, timeout-aware way to react to it; without a timeout on the channel write, "backpressure" quietly becomes "the request handler hangs," which is an availability failure with a different shape than an unbounded queue overflowing. This is the same low-level pattern as TCP windowing above, a full buffer stalling a writer, just one layer higher in the stack.
Trade-offs & pitfalls
- Backpressure without a bounded, timeout-aware reaction just moves the failure upstream rather than preventing it, as the worked example shows; the mechanism only helps if every layer that receives a "slow down" signal has a defined, bounded way to act on it.
- A queue that's too generously sized delays the pain instead of preventing it, and the eventual failure (running out of memory) is harder to diagnose than an early, deliberate rejection would have been.
- Backpressure does not by itself prevent data loss, only unbounded resource growth; if a producer's reaction to a "slow down" signal is to drop the work rather than retry or buffer it durably, backpressure alone is not enough, and a durable retry or dead-letter path is a separate design decision.
- This is a distinct concept from cascading-failure prevention mechanisms like circuit breakers, which stop sending traffic to an unhealthy dependency rather than throttling to match a healthy one's real-time capacity; the two are complementary but answer different questions.
A hot cache entry is about to expire, or a backend service is recovering from an outage, and you expect a flood of simultaneous requests to hit the same key or the same origin at once. Design a comprehensive strategy to prevent this 'thundering herd' across a globally distributed cache and service fleet, covering both techniques that stop the herd from forming in the first place and techniques that contain it once it starts. What would you monitor and alert on to confirm the mitigation is working, and what trade-off would you make between complexity and protection?
Sample Answer
Direct answer
A thundering herd happens when a large number of concurrent requests converge on the same cache key or origin at the same instant, whether that is one hot key expiring, a cache flush after a deploy, or a backend service coming back online after an outage. The strategy has two halves: prevent the herd from forming in the first place (spread out when things expire and who has to pay for a miss), and contain it once it starts (make sure only a small, bounded number of requests actually reach the origin while everyone else waits on or reuses that result). The right mix of techniques is a complexity-versus-protection trade-off: cheap local techniques handle the common case, and the more expensive coordinated techniques get added only where the blast radius or the cost of getting it wrong justifies them.
The simplest version, as a baseline
Two inexpensive techniques already cover a large fraction of real thundering-herd cases: adding random jitter to time-to-live (TTL) values (so a batch of keys set at the same moment do not all expire at the same moment) combined with request coalescing, sometimes called singleflight (so that when a key does expire, only one of the many concurrent requests for it actually fetches from origin, and the rest wait for that one result). Everything below either extends this baseline for a bigger blast radius or handles a different trigger for the same underlying problem.
sequenceDiagram
participant R1 as Request 1..N
participant Svc as Service
participant Lock as Singleflight lock
participant Origin as Origin / DB
R1->>Svc: request for expired key
Svc->>Lock: acquire per-key lock
alt lock acquired
Lock->>Origin: single fetch
Origin-->>Lock: fresh value
Lock-->>Svc: value + refresh cache
else lock held by another request
Lock-->>Svc: wait, then reuse result
end
Svc-->>R1: response (fresh or briefly stale)
Preventative techniques (stop the herd from forming)
- Jittered TTLs: instead of a flat TTL, add randomness (for example, base TTL plus or minus 5-20%) so a set of keys populated together do not expire together.
- Staggered renewals: refresh keys on a schedule spread out over time rather than all at once, particularly for keys that were all warmed at the same moment (after a deploy, a region failover, or a bulk cache-priming job).
- Background refreshers: proactively refresh a known hot key before it expires, so readers never actually observe a miss for that key under normal conditions.
Reactive techniques (contain the herd once it starts)
- Request coalescing / singleflight: on a miss, one request fetches from origin while concurrent requests for the same key wait on that result instead of issuing their own origin calls.
- Leader-based warmers: elect one instance (via a lease, not a permanent role) to be responsible for refreshing a given hot key or key range, so refresh work itself does not become another source of duplicate origin load.
- Per-key locks: a more granular form of coalescing, useful when singleflight is implemented per-process and needs a cross-process equivalent (a short-lived distributed lock) to coordinate across many service instances.
- Circuit breakers: once the origin's error rate or latency crosses a threshold, stop sending it more traffic and serve a fallback (often the stale cached value) instead of piling more failing requests onto an already-struggling origin. In plain terms, a breaker has three states: closed, where traffic flows normally while the breaker just watches the error/latency rate; open, where it stops sending calls to the origin entirely once that rate crosses its threshold, serving the fallback instead; and half-open, where it periodically lets one trial request through to check whether the origin has recovered before fully closing again. The full mechanics of breaker states and how they interact with retries go deeper than that summary; here, the breaker's job is specifically to stop a cache-miss storm from becoming an origin-overload storm.
- Rate limits: cap the request rate a single key or a single origin dependency can absorb, as a hard backstop under the other techniques.
- Grace caches serving stale-while-revalidating: keep serving the last known-good value past its nominal expiration while a refresh happens in the background, rather than making every reader wait on that refresh.
- Queuing / coordinator patterns: for the largest blast-radius cases, put the refresh work behind a queue with a single (or small, bounded) set of workers pulling from it, so origin load during a mass-invalidation event is capped by queue-consumer concurrency rather than by however many callers happened to miss at once.
Thundering herd is not only a cache problem
The same failure shape hits connection pools: a backend service recovering from an outage can see every client that was queued or retrying suddenly reconnect and check out a database or HTTP connection at once, exhausting the pool the same way a cache stampede exhausts an origin. The mitigations are the same family of ideas applied to a different resource: jittered reconnect/retry backoff instead of jittered TTL, and a bounded admission queue at the pool instead of request coalescing at the cache.
Worked incident case study: a stale-price outage
A pricing update was pushed, but the cache continued serving stale cached prices for several minutes, an invalidation gap, not a stampede, but the kind of incident that motivates having both invalidation correctness and stampede protection in the same design: a fix that improves one without the other still leaves revenue exposure on the table. The response has three time horizons:
- Immediate: force-invalidate the specific affected keys (or, if scope is unclear, accept a short blanket TTL reduction) to stop the bleeding, while watching origin load closely since a mass invalidation of price keys is itself a thundering-herd trigger, so this step should go out with jittered expiration or through the coordinator pattern above, not as one giant simultaneous purge.
- Short-term: add an event-driven invalidation path (publish an invalidation message on price update) so the next price change does not depend on a TTL window at all.
- Long-term: add a monitoring check that compares cached price against source-of-truth price on a sample basis, so a future invalidation gap is caught by an alert instead of by a customer or an audit.
Worked scenario: a flash sale
A flash sale that invalidates millions of product keys at once is a deliberately engineered thundering herd, not an accidental one, so it needs a pre-sale runbook rather than relying on steady-state defenses alone:
- Days before: identify the key set that will be invalidated and pre-warm replacement values (new prices, new stock) into a shadow key space.
- Hours before: switch traffic to read from the shadow key space via a fast pointer/alias flip rather than invalidating the live keys directly, avoiding a simultaneous mass-miss entirely.
- At sale start: keep circuit breakers and rate limits armed on the origin regardless, as a backstop for any key the pre-warm missed.
- Post-sale: retire the old key space and confirm hit ratios and origin load returned to baseline.
Concrete trigger: one hot key expiring
The narrowest version of this problem, a single popular key expiring and producing a flood of simultaneous requests that overloads the database, is mitigated specifically by a read-through warmer: a background process that refreshes that specific key proactively, on a schedule tied to its own TTL, so normal readers never observe the miss that would otherwise trigger the flood.
Shipping the mitigation itself safely
Rolling out a stampede fix is its own small project, not a one-shot deploy: use a staged rollout strategy (enable coalescing or jitter for one key namespace or one region first) rather than flipping it on globally, since a bug in the mitigation's own locking logic can create a new outage rather than preventing one. Pair the rollout with validation experiments, a controlled comparison of origin load and P95 (95th-percentile) latency with the mitigation on versus off for equivalent traffic, so the team has evidence the change worked rather than an assumption that it did.
Monitoring and alerting
Track cache miss rate per key (a sudden spike for one key is the earliest signal of a forming herd), waiter/queue depth at the coalescing layer, origin error rate and latency, connection-pool saturation, and circuit-breaker state transitions. Alert on a sharp rise in per-key miss rate or waiter count before it turns into an origin-side incident, not after.
Trade-offs and pitfalls
Local, per-process singleflight combined with TTL jitter is cheap and covers most real traffic patterns; it is the right default. Cross-process coordination (distributed locks, leader election for warmers, a queue-based coordinator) adds real protection for large blast-radius events like a flash sale, but it adds operational complexity, another thing that can itself fail, and some latency overhead from the coordination itself. The main pitfall is reaching for the expensive coordinated techniques everywhere out of caution: that adds failure surface without adding proportionate protection for keys that were never going to produce a large herd in the first place. The second pitfall is validating a mitigation with a synthetic load test but never with a real fault-injection or game-day exercise: a load test proves the happy path holds up under volume, not that the coalescing and breaker logic actually engage correctly the moment a real dependency starts failing.
Unlock Full Question Bank
Get access to all Scalability Patterns and Techniques interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.