Learn a repeatable method to size a system, load test it, keep safe headroom, understand autoscaling limits, and find the cost drivers and levers that matter most.
What you'll learn: By the end of this lesson you will be able to turn a traffic forecast into a capacity number with explicit headroom, design a load test that finds the real limit of a service, explain why autoscaling alone does not protect you, and identify the largest cost drivers in a cloud architecture and the levers to reduce them.
Think of a restaurant planning for Saturday night. The owner does not guess. She looks at last month's Saturdays, expects a bit of growth, knows each cook can plate about so many dishes an hour, and keeps a spare cook in case one calls in sick. She also knows that hiring ten cooks "just in case" would bankrupt the place. Capacity planning is that discipline: enough to serve the busy night, not so much that you pay for empty kitchens every other day.
Software teams get this wrong in two opposite ways. Some under-provision and discover their limit during a launch. Others over-provision out of fear and discover the cloud bill instead. The skill is to make both risks visible with numbers, so the trade-off is a decision rather than an accident.
Capacity planning answers one question: how much of each resource do we need so that, at the busiest expected moment, we still meet our SLO, even when something has failed? Every word matters.
Each resource: CPU, memory, disk and disk I/O, network, database connections, and the capacity of every dependency. The scarcest one sets the limit.
Busiest expected moment: peak, not average. Average load is almost irrelevant for sizing.
Meet our SLO: capacity is defined by latency and error targets, not by "the server did not crash". A service at 100 percent CPU may be alive and useless.
Even when something has failed: size for degraded states, such as the loss of an instance or an availability zone.
A repeatable method
flowchart LR
F["Forecast demand"] --> M["Measure per-unit capacity"]
M --> C["Compute required units"]
C --> H["Add headroom"]
H --> V["Validate with a load test"]
V --> W["Watch and re-plan"]
W --> F
1. Forecast demand
Start from business inputs: users, requests per user, seasonal peaks, planned launches. Convert to peak requests per second, as in the estimation lesson, and add a growth assumption for the planning horizon, say the next quarter. Review the forecast against reality every cycle; if it was off by 50 percent last time, plan with a range.
2. Measure per-unit capacity
Do not guess how much one instance can handle; measure it with a load test. Express the result as "one instance sustains N requests per second at the target utilisation while staying inside the latency SLO". Queues grow sharply near saturation, so planning to run at 60 to 70 percent of the measured maximum is common (an assumption to set per system).
3. Compute required units
Work through a small example with illustrative numbers. Assume a forecast peak of 2,000 requests per second, measured safe capacity of 250 requests per second per instance at the latency target, a 30 percent growth buffer, three availability zones, and a requirement to survive the loss of one zone while still meeting the SLO.
Step
Calculation
Result
Base instances for forecast peak
2,000 / 250
8
With 30 percent growth buffer
8 × 1.3
10.4, round up to 11
Survive one zone lost (two zones carry the load)
11 / 2 per zone, round up to 6 per zone
6 per zone, 18 total
So the plan is 18 instances in steady state although only 8 are strictly needed at forecast peak. The extra 10 are the price of growth room and zone resilience. If that price is too high, make a conscious trade: accept degraded service (shed low-priority work) during a zone loss and plan 12 instances (4 per zone, leaving 8 after a zone loss: enough for the forecast peak but not the growth buffer). That is a business decision, and now it is visible.
Little's Law is a second tool: the average number of requests in the system equals arrival rate times average time in the system. At 2,000 requests per second and 100 ms average latency, about 200 requests are in flight at once. That number sizes thread pools, connection pools and memory per instance.
4. Add headroom
Headroom is deliberate spare capacity that absorbs forecast error, spikes, slow deploys and the time it takes to add capacity. Its sources are a utilisation target below the saturation knee, N+1 or N+2 redundancy, a growth buffer until the next review, and deploy slack, since rolling deployments temporarily remove instances.
5. Validate, watch, re-plan
A plan is a hypothesis. Validate it with a load test, track actual utilisation and saturation against it in production, and re-run the plan on a schedule and before known events.
Load testing
A load test sends synthetic traffic and measures how latency, errors and resource usage change. Different tests answer different questions.
Test type
What it does
Question it answers
Load test
Ramps to the expected peak and holds
Do we meet the SLO at forecast load?
Stress test
Keeps increasing until failure
Where is the breaking point, and how does it fail?
Spike test
Sudden jump in traffic
How does the system react to bursts, and how fast does autoscaling respond?
Soak test
Moderate load for many hours
Are there leaks, slow growth in latency, or resource exhaustion?
Good practice:
Use realistic traffic mixes and data. A test that hits one cached URL measures the cache, not your system, and an index over 1,000 rows behaves nothing like one over 500 million.
Watch the whole path, including databases, caches, queues and downstream services. The bottleneck is often not where you expect.
Find the knee. Plot latency against throughput: latency stays flat, then bends sharply upward. Capacity is a bit to the left of that bend.
Avoid the coordinated-omission trap. A load generator that waits for slow responses before sending the next request under-reports the delays real users would see. Use a fixed arrival rate.
Test in an environment that matches production closely enough to trust, and be very careful when testing against production.
WARNING
A load test can hit shared dependencies, send real emails or charge real cards. Use sandbox integrations or stubs for external calls, and make sure the test cannot reach real customer side effects.
Autoscaling and its limits
Autoscaling adds or removes instances based on a metric such as CPU, request rate or queue depth. It is excellent for smoothing daily cycles and saving money. It is not a substitute for planning.
sequenceDiagram
participant T as Traffic
participant M as Metrics
participant A as Autoscaler
participant N as Node pool
participant P as New pod
T->>M: spike begins
M->>A: metric scraped after a delay
A->>N: need more pods, request nodes
N->>P: node ready, image pulled
P->>P: start process and warm caches
P-->>T: passes readiness, takes traffic
Note over T,P: Every step adds seconds, headroom covers the gap
Read it as a chain of delays: detection, node provisioning, image pull, start-up and warm-up each add time, and a sharp spike can overwhelm the existing fleet before new capacity arrives. Other limits:
It scales one tier, but the bottleneck may be elsewhere. More application servers make a saturated database worse by opening more connections.
Quotas and capacity exist. Accounts have quotas, subnets have finite addresses, node groups have maximum sizes, and instance types can run out in a zone. Set maximums on purpose and request quota increases before an event.
Metrics can mislead. CPU is a poor signal for I/O-bound services; queue depth or concurrency often works better. Use cooldowns so the policy does not flap.
Cold starts. New instances start with empty caches, so pre-warm or ramp traffic gradually.
Scaling down is risky too. Removing instances while connections or jobs are in flight needs graceful draining.
Combine reactive autoscaling for the unexpected with scheduled scaling for the predictable, such as a known sale at noon, and pair both with load shedding, as in resilience patterns.
Where the money goes
Cost is a system property just like latency. Before optimising, look at what drives the bill. The categories are common across providers, though pricing models and rates differ and change, so check your own provider's current pricing.
Cost driver
What drives it
Typical levers
Compute
Instance hours, size, number
Right-size, autoscale, commitment discounts, spot or preemptible capacity for fault-tolerant work
Storage
Volume of data and the tier it sits in
Lifecycle rules to cheaper tiers, compression, deleting what you do not need
Data transfer (egress)
Bytes leaving a region or the provider
CDN, keep chatty services in one zone or region, compress, cache
Managed services
Provisioned capacity plus request charges
Match provisioning to need, review idle resources
Observability
Log, metric and trace volume
Sampling, retention limits, dropping noisy logs
Cross-region and cross-zone traffic
Replication and service calls across boundaries
Locality-aware routing, replicate only what you need
Two patterns recur. Data transfer is the surprise: moving bytes between regions, or out to the internet, can cost more than the compute that produced them. Idle waste is common: forgotten development environments, over-sized databases and unattached volumes quietly accumulate. Every resilience measure also has a price: a fully running second region roughly doubles infrastructure, as discussed in multi-region and disaster recovery, and the growth buffer plus zone redundancy create the 18-versus-8 gap above. Treat these as insurance premiums chosen per system.
When a bill surprises you, triage it in this order.
flowchart TD
B["Bill above forecast"] --> U{"Did traffic grow as much?"}
U -->|yes| F1["Forecast was wrong, update the model"]
U -->|no| T{"Which line grew most?"}
T -->|"egress or transfer"| E1["Find paths bypassing the CDN or crossing zones"]
T -->|"compute"| E2["Check max replicas, idle environments, node sizes"]
T -->|"observability"| E3["Check log level, cardinality, retention"]
T -->|"managed services"| E4["Check provisioned tiers and unused instances"]
Build versus buy
For each component you can build it, run open-source yourself, or buy a managed service. Compare the total cost of ownership, not only the invoice.
Option
Strengths
Costs and risks
Fits when
Managed service
Fast to start, operated for you, built-in scaling and backups
Higher unit price, less control, provider limits and lock-in
The component is not your differentiator and your team is small
Self-run open source
Control, portability, lower unit price at scale
Engineer time for upgrades, tuning, on-call, patches
You have the expertise and scale where savings are real
Build from scratch
Perfect fit, possible competitive edge
Highest engineering and maintenance cost, hidden bugs
It is core to the product and nothing existing fits
Include people cost: an engineer spending a quarter of their time keeping a database cluster healthy is a real expense that never appears on the cloud invoice. Include the exit cost too. Many teams buy at small scale and revisit when the bill grows large enough to justify the engineering.
Efficiency levers
Before buying more capacity, make each unit do more. Roughly in order of payoff:
Remove work. Cache results, avoid repeated queries, batch small operations.
Fix the hot spots. One index can save a database tier.
Right-size to the measured bottleneck: memory-heavy, compute-heavy or I/O-heavy.
Use the right storage tier and expire data on a schedule.
Reduce bytes. Compression, smaller payloads, image resizing and a CDN cut bandwidth and egress.
Smooth demand. Move non-urgent work to a queue and run it off-peak, lowering the peak you provision for.
Buy commitment discounts only after usage is stable, for the baseline you are confident about.
TIP
Track unit cost: cost per thousand requests, per active user or per order. Total cost rising with traffic is normal. Unit cost rising is a signal that something regressed.
In practice: an e-learning platform's surprise cloud bill and its term-start capacity plan
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
An online learning platform serves about 150,000 enrolled students with a platform team of eight. Lessons are mostly video; quizzes and discussion are database-backed. The stack: Kubernetes with a horizontal pod autoscaler, managed PostgreSQL with one read replica, Redis for sessions and caching, S3-compatible object storage behind a CDN for video, OpenSearch for course search, RabbitMQ for quiz-grading jobs, and Prometheus and Grafana. Two problems. First, last month's cloud bill came in at roughly twice the forecast, and nobody could say why in under a day. Second, term starts on Monday at 09:00 and last term the first 20 minutes brought slow logins and a database at its connection limit. Assume the next term's forecast is 30,000 concurrent active students in the first 15 minutes, each generating about 0.1 requests per second, so 3,000 requests per second at peak. Constraint: finance wants the monthly bill within 15 percent of forecast.
Starting architecture
Options considered
Option
What it fixes
What it costs
Risk
Do nothing, rely on the HPA
Nothing new to build
Zero effort
Spike outruns scaling; database connections exhausted again; bill stays unexplained
Plan-first package: bill triage, CDN and log fixes, scheduled scale-up, connection pooler, quota checks, unit-cost dashboard
Explains and trims the bill; protects term start
A few weeks of work, a pre-event checklist
Low to medium: scheduled capacity costs money for a few hours
Move to the largest database tier and buy a one-year commitment for everything
Removes the database ceiling quickly
High fixed cost
Locks in spend before usage is understood
The decision
Field
Entry
Context
Unexplained 2x bill; last term's database connection exhaustion; a hard Monday 09:00 peak.
Decision
Adopt the plan-first package. Capacity maths: 3,000 requests per second at 150 per pod is 20 pods; a 20 percent buffer gives 24; losing one of three zones requires 12 per zone, so 36 pods. Scheduled scaling holds 36 pods from 08:30 to 11:00 on term-start days. Pod pool size of 20 across 36 pods is 720 connections, above the database limit assumed at 500, so a connection pooler goes in front of PostgreSQL. Little's Law at 0.2 s average latency gives about 600 requests in flight. Commitment discounts only for the baseline after two months of stable data.
Consequences
Good: term start is planned, the bill has an owner and a unit-cost metric. Bad: about two and a half hours of extra compute per term-start day, one more component (the pooler) to operate.
Revisit when
Unit cost per active student rises two months in a row, or the forecast peak exceeds 5,000 requests per second.
Rollout and verification
flowchart LR
R1["Bill triage and quick fixes"] --> R2["Load test at 1.2x forecast"]
R2 --> R3["Add pooler and scheduled scaling"]
R3 --> R4["Pre-event limits checklist"]
R4 --> R5["Term-start day with shedding ready"]
Triage first: in this scenario assume the surprise came from one video path serving files straight from object storage instead of through the CDN, cross-zone traffic between pods and the search cluster, and debug logging left on. Fix those and confirm with the next daily cost report. Then run a load test with a fixed arrival rate at 1.2 times the forecast, replaying a realistic mix of logins, lesson views and quiz submissions against production-sized data in a staging copy, and record where latency bends. Verify before the event: HPA and node group maximums above the planned pod count, account quota above the maximum nodes, database connections under the limit with margin, replica lag acceptable. Dashboards: API p99 and error rate, pod count versus desired, pending pods, database connections and queue depth, cache hit ratio, and daily cost with cost per active student.
The 2 a.m. failure
At 09:05 on term-start Monday the page reads "API p99 above 1.5 seconds, 20 pods Pending". The scheduled scale-up asked for 36 pods, but the node pool's maximum size was left at last term's value, so only 16 pods can be scheduled, about 2,400 requests per second of capacity against a 3,000 peak.
sequenceDiagram
participant S as Scheduled scaler
participant K as Kubernetes
participant CA as Cluster autoscaler
participant OC as On-call
S->>K: desired replicas 36
K->>CA: 20 pods unschedulable
CA-->>K: node group at max size
K-->>OC: alert pending pods and p99
OC->>K: describe pending pod
OC->>CA: raise node group maximum
OC->>K: disable typeahead and recommendations flag
CA-->>K: new nodes join
First three checks: list pods in the Pending phase; kubectl describe one to read the scheduling event (insufficient CPU, or node group max reached); and read the cluster autoscaler status for the node group limit. Mitigation: raise the node group maximum, and in the meantime turn off the low-priority flags (search typeahead and recommendations) to shed load until the pods land. Follow-up fix: add every limit (node group maximum, account quota, subnet addresses, database connections) to the pre-event checklist, and alert whenever desired capacity exceeds the configured maximum.
Architect's notes
Measure unit cost before optimising. Cost per active student per day tells you whether the bill is growing because of success or regression.
Ask in review: "what is the limit of every tier, and who knows it?" Node maximums, quotas, connections and IP ranges are where plans quietly fail.
Egress and cross-zone traffic are the late surprises. Model bytes moved between components in the design, not after the first invoice.
Give the bill an owner. Tag resources by team and review cost weekly with the same ritual as reliability; an unowned line item grows.
Rehearse the peak, not the average. A load test at a fixed arrival rate before each term is cheaper than one incident.
Defer commitments and rewrites. Buy discounts for the proven baseline only, and delay re-platforming until the efficiency levers are exhausted.
Common mistakes
Planning for average load. Peaks cause the outages. Size for peak, then add headroom.
Guessing per-instance capacity. Without a load test the number is fiction. Measure it against the SLO.
Running at 90 to 100 percent utilisation to "save money". Queueing delay explodes near saturation. Plan to a target below the knee.
Relying on autoscaling alone. It lags, hits quotas and cannot fix a saturated downstream. Add headroom, scheduled scaling and shedding.
Ignoring data transfer and cross-zone traffic. The bill can be dominated by bytes moved, not compute. Model it early.
Forgetting people cost in build-versus-buy. Self-hosting is not free. Include operations effort and risk.
Never revisiting the plan. Review utilisation and forecasts on a schedule and before events.
Key takeaways
Capacity means meeting the SLO at peak, with a failure in progress. Average load and "not crashed" are the wrong measures.
Plan in five steps. Forecast, measure per-unit capacity, compute, add headroom, validate and re-plan.
Headroom is a decision. Utilisation targets, N+1 redundancy and growth buffers each have a price you can state.
Load tests find the knee. Use realistic traffic and data, a fixed arrival rate and the whole dependency path.
Autoscaling is a smoother, not a safety net. It lags and can be blocked by quotas or a downstream bottleneck.
Egress and idle resources drive many surprise bills. Measure data transfer and hunt waste.
Build versus buy is total cost of ownership. Include engineer time, risk and exit cost.
Remove work before adding hardware. Caching, indexes, compression and batching usually beat bigger machines.
Practice exercises
Exercise 1 — Starter (30 min): A service must handle a forecast peak of 5,000 requests per second. A load test shows one instance holds its latency target up to 400 requests per second. Using a 25 percent growth buffer and surviving the loss of one of three zones, calculate the number of instances needed and show each step. Then calculate how many in-flight requests exist at peak if the average latency is 80 ms.
Exercise 2 — Intermediate (2 hours): Design a load-test plan for an API that has a cache in front of a database. Specify the traffic mix (read-write ratio, cache hit assumption), the data volume, the ramp and hold profile for a load test, a stress test and a soak test, the metrics you will record at each tier, and how you will tell where the knee is. List three results that would make you change the architecture.
Exercise 3 — Advanced (half day): Take a design from this course (for example the photo-sharing app or the URL shortener) and build a monthly cost model. Assume traffic, storage growth and egress, and list every cost category from the table. Identify the top three cost drivers, propose one lever for each with an estimated percentage saving (clearly labelled as an assumption), and write a build-versus-buy recommendation for the database, the queue and the search index including people cost.
Lesson 8 of 14 · Advanced System Design and Case Studies
Learn a repeatable method to size a system, load test it, keep safe headroom, understand autoscaling limits, and find the cost drivers and levers that matter most.
In the rest of this lesson
Load testing
Autoscaling and its limits
Where the money goes
Build versus buy
Efficiency levers
In practice: an e-learning platform's surprise cloud bill and its term-start capacity plan
Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.