system-design

Capacity Planning and Autoscaling

How much do you need, and how do you get more automatically? The answers involve arithmetic you already know and a few traps that catch people who trust autoscaling too much.

Prerequisites: Back-of-the-Envelope Estimation, Performance Metrics Time to read: ~16 minutes


The two questions

Capacity planning — how much infrastructure do you need? This is back-of-the-envelope estimation applied to provisioning: QPS × cost-per-request, storage growth, bandwidth. You already have the skill.

Autoscaling — how do you automatically add and remove capacity as load changes, so you’re not manually provisioning for a peak you hit twice a day?

🚨 The tension: provision too little and you fall over at peak; provision too much and you pay for idle capacity. Autoscaling promises to solve this by matching capacity to demand — but it has real limits that make capacity planning still necessary.


Capacity planning: the arithmetic

Same recipe as estimation:

1. Peak QPS         = daily requests / 86,400 × peak-multiplier (2-10×)
2. Servers needed   = peak QPS / per-server capacity
3. Add headroom     = servers × 1.5 (run at ~65%, not 100%)
4. Add redundancy   = survive N failures (N+2)
5. Storage          = data/day × retention × replication (×3) × index overhead
6. Bandwidth        = QPS × payload size

🚨 Two things people get wrong:

Peak, not average. Provisioning for average means being overloaded every peak. The peak multiplier depends on the workload — 2-3× for a globally-spread consumer app, 10×+ for a regionally-concentrated one (food delivery at dinner), 100×+ for event-driven spikes (ticket sales).

Headroom for queueing. 🚨 Run at ~65% utilization, not 100% — because queueing delay grows non-linearly as you approach saturation, and you need slack to absorb variance and survive a lost instance. Provisioning to exactly 100% means terrible p99 and no failure tolerance.


Autoscaling: how it works

Automatically adjust capacity based on metrics:

Horizontal scaling — add/remove instances (the usual and preferred way; requires stateless services). → Kubernetes HPA

Vertical scaling — resize instances (bigger machines). Limited, usually needs a restart.

What to scale on:

Scale-out vs scale-in asymmetry — 🚨 scale out aggressively (add capacity fast when load rises), scale in conservatively (remove slowly, to avoid thrashing and to have headroom if load returns). Removing capacity too eagerly means you scale out again moments later — flapping.


The traps (why autoscaling isn’t magic)

🚨 These are the interview-relevant limits, and trusting autoscaling blindly causes outages:

1. Lag. Autoscaling isn’t instant — new instances take seconds to start (image pull, boot, warm-up), new nodes take minutes. So autoscaling handles gradual load changes but 🚨 cannot handle sudden spikes or cascading failures (which complete in under 2 minutes — faster than instances start). For spikes you need headroom, load shedding, and queues — not autoscaling.

2. Cold starts / warm-up. 🚨 New instances start cold — empty caches, unwarmed connection pools, uncompiled hot paths (JIT). Their first requests are slow, and they hammer the database with cache misses. Scaling up under load can briefly make things worse. Mitigate with slow-start ramping, readiness probes that only pass when warm, and cache warming. → Scalability

3. The database doesn’t autoscale (easily). 🚨 You autoscale the app tier to 50 instances, and they all hammer one database with 50× the connections — which falls over. The stateful tier is the constraint, and it doesn’t scale as easily. Autoscaling the app tier without considering the database is a classic mistake. → Connection Pooling

4. Downstream dependencies. Scaling up means more load on everything downstream (databases, third-party APIs, other services). Scaling your service can just move the bottleneck — or overwhelm a dependency that can’t scale with you.

5. Cost runaway. 🚨 Autoscaling + a bug (a retry storm, a traffic flood) = a huge bill, because the system scales up to serve the bad load rather than falling over. Set maximum limits and cost alarms. → DDoS / denial of wallet

6. Flapping. Aggressive scale-in causes oscillation — scale down, load returns, scale up, repeat. Use cooldowns and conservative scale-in.

🎙️ “Autoscaling handles gradual load, but it has real limits — it lags (instances take seconds, nodes minutes), so it can’t absorb sudden spikes or cascades; new instances are cold and can briefly make things worse; and the database tier doesn’t scale with the app tier, so I’d watch connection exhaustion. I’d keep headroom for spikes and set maximum limits so a bug doesn’t scale us into a huge bill.”

This nuanced “autoscaling isn’t magic” answer is a strong signal — many candidates propose autoscaling as a cure-all.


Load testing: know your numbers

🚨 You can’t plan capacity or configure autoscaling without knowing your actual per-instance capacity and failure point. Load testing gives you these:

Types: load testing (expected load), stress testing (beyond capacity, to find the breaking point), spike testing (sudden surge, to test autoscaling lag), soak testing (sustained load, to find leaks). Tools: k6, Gatling, Locust, JMeter, wrk.


⚖️ Trade-offs

Choice Gain Cost
Autoscaling Match capacity to demand; cost efficiency Lag; cold starts; can’t handle spikes; complexity
Over-provisioning Handles spikes instantly; simple Pay for idle capacity
Scale on CPU Simple default Not always the right signal (downstream-bound services)
Scale on custom metrics Accurate to the real bottleneck More setup
Aggressive scale-in Cost savings Flapping; no headroom if load returns
Reserved capacity Cheaper for baseline Less flexible

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Find your per-instance capacity. Load-test a single instance with k6, ramping up QPS until p99 degrades. That number is what all your capacity planning divides by — and it’s usually lower than people guess.

2. Find the breaking point. Keep pushing past capacity. What breaks first — CPU, memory, connections? Knowing how it fails tells you what to scale on and where the real bottleneck is.

3. Test autoscaling lag with a spike. Configure autoscaling, then hit the service with a sudden 10× spike. Watch requests fail while new instances start up — the lag is real, and it shows why autoscaling alone can’t handle spikes.

4. Cause the cold-start problem. Scale up under load and measure the new instances’ first-request latency and the database’s cache-miss rate. Watch the scale-up briefly stress the system before the new instances warm up. Then add cache warming and compare.


Check yourself

1. Why must you provision for peak rather than average, and why not to 100%? **For peak** because you must be able to serve your busiest moment — provisioning for average traffic means being overloaded and dropping requests during every peak, which for a consumer app is a few hours every day. The peak multiplier over average depends on the workload: a globally-distributed consumer app whose users span time zones is relatively flat (2-3× average), a regionally-concentrated app has a sharp daily peak (10×+ — food delivery at dinner), and event-driven systems spike enormously (100×+ for ticket sales or a viral moment). **Not to 100%** because utilization and latency have a non-linear relationship: as a system approaches saturation, queueing delay grows explosively (the u/(1-u) relationship — at 90% utilization, wait times are ~9× the service time), so a system running at 100% has terrible tail latency even before it fails. You also need slack to absorb normal variance and, critically, to survive losing an instance — if you're at 100% across N instances and one dies, the rest are instantly over capacity, triggering a cascade. Running at ~65% keeps latency healthy and leaves room to absorb spikes and failures.
2. Why can't autoscaling handle sudden spikes or cascading failures? Because it's too slow to react. Autoscaling observes a metric (CPU, queue depth), decides to add capacity, and then that capacity takes time to become available: new instances take *seconds* to start (pull the image, boot, warm caches and connection pools before serving), and if the existing nodes are full, provisioning new *nodes* (machines) takes *minutes* (cloud VM creation, node join, scheduling). A sudden spike (a flash sale, a viral event) arrives in seconds and overwhelms current capacity long before new capacity comes online. A cascading failure unfolds even faster — failing instances shift their load to survivors, which then also fail, completing the collapse in under two minutes, far quicker than autoscaling can respond. Autoscaling is designed for *gradual* load changes (traffic growing over minutes to hours), where its reaction time is adequate. For spikes and cascades you need mechanisms that work instantly: pre-provisioned headroom to absorb the surge, load shedding to reject excess and protect the core, rate limiting, circuit breakers, and queues to buffer bursts. Relying on autoscaling to catch a spike is a classic cause of outages.
3. Why can scaling up under load briefly make things worse? Because new instances start *cold* and need warming up before they're fully effective, and during that warm-up they can add stress rather than relieve it. A freshly-started instance has: empty in-process caches (so its requests miss and go to the database), unwarmed connection pools (establishing connections adds latency), and for JIT-compiled runtimes (JVM, Node), uncompiled hot code paths that run slowly until the compiler optimizes them. So when you scale up during a load-induced slowdown, the new instances serve their first requests slowly *and* hammer the already-stressed database with a burst of cache misses (since their caches are empty), which can push the database further toward the edge — the intervention meant to help briefly hurts. This is counterintuitive and catches people out: adding capacity during an incident can deepen it before it helps. Mitigations: slow-start ramping (route a gradually-increasing fraction of traffic to new instances so they warm before taking full load), readiness probes that only pass once the instance is genuinely warm (so the load balancer doesn't send traffic prematurely), pre-warming caches and connection pools on startup, and keeping enough headroom that you rarely need to scale reactively under stress in the first place.
4. Why is autoscaling the app tier while ignoring the database a classic mistake? Because the app tier is easy to scale but the database — the stateful tier — isn't, so scaling the app tier just moves the bottleneck to the database and can take it down. When autoscaling grows your stateless app tier from 5 to 50 instances under load, each instance opens its own pool of database connections, so the database suddenly faces 50× the connection count — and databases handle a limited number of connections well (Postgres allocates a backend process per connection, thrashing beyond a few hundred), so it exhausts connections, refuses new ones, and slows to a crawl, taking down the very service you scaled. More broadly, the app tier's throughput is often ultimately limited by the database's capacity, so adding app instances past that point yields no benefit and only adds pressure. The database doesn't autoscale the way stateless instances do — you can't just add replicas instantly, sharding is a major undertaking, and vertical scaling has limits. So capacity planning must account for the whole chain: the app tier's scaling is bounded by what the database (and other downstream dependencies) can absorb. Mitigations: a connection pooler (PgBouncer) between the app tier and database to multiplex many app connections onto few database ones, read replicas to offload reads, caching to reduce database load, and recognizing that the stateful tier is usually the real scaling constraint.
5. Why do you need maximum scaling limits and cost alarms on autoscaling? Because autoscaling responds to load by adding capacity, and it can't distinguish *legitimate* load from *bad* load — so a bug or an attack that generates excessive requests causes the system to scale up to serve it, converting what would have been an outage into a massive bill. Examples: a retry storm where a failing dependency causes clients to retry aggressively, multiplying request volume; a recursive trigger (a function that invokes itself); a traffic flood or DDoS; or a runaway loop. In a fixed-capacity system, excess load causes degradation or an outage — bad, but bounded. In an autoscaling (especially serverless) system, the platform dutifully scales up to meet the demand and bills you for all of it, with no natural ceiling — there are well-documented cases of accidental five- and six-figure bills from a single misconfiguration. This is "denial of wallet" in the extreme. So you set a **maximum** number of instances (or concurrency) so scaling can't run away, and **cost alarms/budgets** that alert (or auto-throttle) when spend spikes abnormally, so a runaway is caught in minutes rather than discovered on the invoice. The maximum limit means that under a genuine overwhelming spike you degrade gracefully (shed load) rather than scaling infinitely — which is usually the correct trade-off.

Further reading