Skip to content

Capacity Planning

The business asks a simple question: "Can we put the stadium show on sale on March 14 at 10:00?" Behind it are engineering questions with numeric answers: how many replicas of each service, how big the database, how many connections, what admission rate for the waiting room, how early to scale up β€” and how much it costs. Capacity planning2 turns the measurements of the previous pages into those numbers, with enough margin to survive what you did not predict.


From measurements to replicas

Capacity planning is arithmetic on three inputs you now know how to obtain:

  1. Demand β€” the peak arrival rate per endpoint, from the workload model (forecast Γ— safety factor).
  2. Capacity per unit β€” the knee of one pod (or one database) from a breakpoint test: the highest rate at which it still meets the SLO.
  3. Headroom β€” how far below the knee you want to run, and which failures you must survive.
\[ \text{replicas} = \left\lceil \frac{\text{peak demand}}{\text{knee per replica} \times (1 - \text{headroom})} \right\rceil \quad\text{then add replicas to survive the loss of one zone} \]

Little's Law sizes everything that holds a request

Replicas are not the only resource. Every component that holds a request while it is in progress β€” threads, connections, sockets, VUs, queue slots β€” must be sized for the concurrency, and Little's Law gives it directly: concurrency = arrival rate Γ— time held1.

Resource Rate Time held Concurrency needed
Request threads of inventory 3 000 holds/s 120 ms (p50 response) 360 threads across all pods β€” more for the tail
PostgreSQL connections 3 000 holds/s 6 ms per transaction 18 active connections (behind PgBouncer)
k6 VUs for the load test 1 000 hold() iterations/s 3.5 s per iteration 3 500 VUs
Waiting room 500 fans admitted/s position 600 000 in the queue 600 000 / 500 = 1 200 s: a 20-minute wait to show the fan

A capacity model for the on-sale

The model below combines the workload model's peak rates with per-pod knees measured in breakpoint tests (all values are illustrative assumptions β€” replace them with your measurements). It plans to run at most at 75 % of the knee and spreads replicas over three availability zones so that losing one zone still leaves enough capacity:

ServiceKnee per pod (req/s)Peak, no waiting roomPods (load β†’ +zone loss)Peak, waiting roomPods (load β†’ +zone loss)
gateway4,00030,00010 β†’ 1530,00010 β†’ 15
catalog1,50019,50018 β†’ 2719,50018 β†’ 27
search6003,0007 β†’ 123,0007 β†’ 12
inventory2754,50022 β†’ 333,00015 β†’ 24
order1503,00027 β†’ 422,00018 β†’ 27
PostgreSQL client connectionspool 10/pod1,020780

What the model says:

  • The waiting room saves capacity where it is most expensive. Capping admitted holds at 3 000/s (below the database's knee) cuts inventory and order replicas by about a third β€” and, more importantly, keeps the load on the single primary database bounded.
  • Zone resilience costs 50 % more replicas at the peak. That is the price of "a zone outage at 10:01 does not stop the sale". It may be worth it on on-sale day and not on a Tuesday β€” autoscaling bounds can differ by day.
  • The connection count is a red flag. Close to a thousand client connections cannot go directly to PostgreSQL; PgBouncer (transaction pooling) multiplexes them onto a few dozen server connections β€” the number Little's Law actually requires.
  • Pods become nodes become dollars. About a hundred pods at the peak is roughly the "+24 nodes for 2 hours" of the FinOps cost model.

Headroom: why not run at 100 % of the knee?

Reason for headroom What happens without it
Forecast error The show is more popular than expected; demand exceeds the plan
Sub-minute bursts Dashboards average over a minute; inside it, some seconds carry 2–3Γ— the average
Failures A zone, a node or a pod is lost; the survivors absorb its load
Deployments Rolling updates and canaries temporarily remove or divert capacity
Autoscaling lag New capacity arrives minutes after the demand
Noisy neighbours, GC, compaction Capacity per pod varies run to run

Typical targets run services at 50–75 % of their knee at the expected peak; the right number depends on how fast you can add capacity and how expensive a breach is. For BoxOffice's on-sale β€” minutes long, highly visible, impossible to repeat β€” generous headroom is cheap insurance.


Autoscaling lag: the minutes that matter

Autoscaling reacts to demand; it does not anticipate it. A rough timeline for a JVM service on Kubernetes when the spike starts:

Step Typical duration
Metrics pipeline notices the change (scrape + aggregation) 15–60 s
HPA evaluation and decision up to 15 s
Pod scheduled β€” if a node has room; otherwise node provisioning seconds / 1–3 min
Image pull on a new node seconds to a minute
JVM start, Spring context, readiness, JIT warm-up 20–60 s
Total β‰ˆ 1–4 minutes

During that window the backlog grows at the difference between demand and capacity. If holds jump from 500/s to 4 500/s while the deployment can serve 1 100/s for two minutes, the backlog is \((4\,500 - 1\,100) \times 120 \approx 408\,000\) requests β€” far beyond any timeout. They become errors, retries make it worse, and the fans who arrived first get the worst experience. Hence the rules for known peaks:

  • Pre-scale before the event (a scheduled scale-up at 09:45; KEDA's cron scaler; raised minReplicas), and keep spare node capacity.
  • Admission control (waiting room, rate limiting) so that excess demand waits outside the system, in order, instead of timing out inside it3.
  • Warm up the new capacity: caches pre-loaded with the event, connection pools filled (minimum-idle = maximum-pool-size), a warm-up request to each new pod before it becomes ready.

Serverless platforms shorten the lag but do not remove it: new function instances have cold starts, and providers limit how fast concurrency may grow and how much concurrency an account may use (check the current quotas of your provider).

Try it: the scenario below sends a burst to a serverless component. Run it with the default settings and watch the cold-start latency at the start of the burst and the throttled (rejected) requests when the concurrency limit is reached. Then raise the limit or add provisioned (pre-warmed) concurrency, and compare the first seconds of the burst β€” the serverless version of pre-scaling.


Forecasting and the capacity review

Capacity planning is not done once. A simple, sustainable process:

  • Forecast demand from history: trend (growth per month), seasonality (weekends, holidays) and events (on-sales are announced in advance β€” use the calendar, not only the trend).
  • Re-measure capacity per unit after every significant change; a new feature can move the knee more than any infrastructure change. The performance budget in CI (below) catches most regressions before they reach production.
  • Review monthly: peak utilization vs plan, headroom left, cost per unit of demand, upcoming events. Capacity, SLOs and cost belong in the same meeting.
  • Prefer elasticity to precision: a system that scales out in one minute needs a less precise forecast than one that needs a week to order hardware.

Performance budgets in CI

A performance regression that reaches production is found by fans. A performance budget is a limit β€” per endpoint, per page, per build β€” that the pipeline enforces on every change, the same way tests enforce correctness4. Useful budgets mix noisy and deterministic measures:

Budget Example for BoxOffice Noise How to enforce
Latency at a fixed moderate rate POST /holds p95 < 150 ms at 20 holds/s Medium–high on shared CI runners k6 thresholds with margin + comparison with main's baseline
Error rate < 0.1 % Low k6 threshold
Queries per request GET /orders/{id}: ≀ 3 SQL statements None (deterministic) Integration test counting statements (catches N+1 reliably)
Payload size Seat-map JSON ≀ 60 KB None Contract test
Allocations / CPU per request ≀ baseline + 10 % Low (with JMH or a profiler) Micro-benchmark job
Image size and startup time Image ≀ 250 MB; ready in ≀ 30 s Low Pipeline step on the built image

The k6 budget is a short, open-model test against a production-like stack started inside the job. It is not a capacity test β€” CI runners are small and noisy β€” but it reliably catches the big regressions: a missing index, an N+1, a synchronous call added to the hot path.

k6/budget.js
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
import { browse, hold } from './lib/boxoffice.js';
export { browse, hold };

export const options = {
  scenarios: {
    browse: { executor: 'constant-arrival-rate', exec: 'browse', rate: 100, timeUnit: '1s',
              duration: '3m', preAllocatedVUs: 50, maxVUs: 200 },
    hold:   { executor: 'constant-arrival-rate', exec: 'hold', rate: 20, timeUnit: '1s',
              duration: '3m', preAllocatedVUs: 100, maxVUs: 300 },
  },
  thresholds: {
    'http_req_duration{name:GET /events/{id}}': ['p(95)<80'],
    'http_req_duration{name:POST /holds}':      ['p(95)<150'],
    http_req_failed: ['rate<0.001'],
    dropped_iterations: ['count==0'],
  },
};

export function handleSummary(data) {
  return {
    stdout: textSummary(data, { indent: ' ', enableColors: false }),
    'results/summary.json': JSON.stringify(data),
  };
}
.github/workflows/perf-budget.yaml
name: perf-budget
on:
  pull_request:
  push: { branches: [ main ] }        # main's runs become the baseline

jobs:
  k6:
    runs-on: ubuntu-latest
    permissions: { contents: read, actions: read }
    steps:
      - uses: actions/checkout@v4
      - name: Start BoxOffice with production-like settings
        run: docker compose -f compose.yaml -f compose.perf.yaml up -d --build --wait
      - name: Seed a 60 000-seat event
        run: docker compose exec -T db psql -U postgres -d boxoffice -f /seed/onsale.sql
      - uses: grafana/setup-k6-action@v1
      - name: Warm up (not measured)
        run: k6 run --quiet -e BASE_URL=http://localhost:8080 --vus 5 --duration 30s k6/smoke.js
      - name: Performance budget (fails on threshold breach)
        run: mkdir -p results && k6 run -e BASE_URL=http://localhost:8080 k6/budget.js
      - name: Compare with the latest baseline from main
        if: github.event_name == 'pull_request'
        env: { GH_TOKEN: "${{ github.token }}" }
        run: |
          id=$(gh run list --workflow perf-budget --branch main --status success --limit 1 \
                 --json databaseId -q '.[0].databaseId')
          gh run download "$id" --name k6-summary --dir baseline || echo "no baseline yet"
          python3 k6/compare.py results/summary.json baseline/summary.json --max-regression 0.15
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: k6-summary, path: results/summary.json }
k6/compare.py
import argparse
import json
import os
import sys

parser = argparse.ArgumentParser(description="Compare k6 p95 per endpoint with a baseline")
parser.add_argument("current")
parser.add_argument("baseline")
parser.add_argument("--max-regression", type=float, default=0.15)
args = parser.parse_args()


def p95_by_endpoint(path):
    metrics = json.load(open(path))["metrics"]
    return {name: m["values"]["p(95)"] for name, m in metrics.items()
            if name.startswith("http_req_duration{name:")}


current = p95_by_endpoint(args.current)
baseline = p95_by_endpoint(args.baseline) if os.path.exists(args.baseline) else {}
failed = False
for name, value in sorted(current.items()):
    ref = baseline.get(name)
    if ref is None:
        print(f"{name}: {value:.1f} ms (no baseline)")
        continue
    change = (value - ref) / ref
    regression = change > args.max_regression
    failed |= regression
    print(f"{name}: {ref:.1f} -> {value:.1f} ms ({change:+.0%}){'  REGRESSION' if regression else ''}")
sys.exit(1 if failed else 0)

Two gates protect the budget: the absolute thresholds in budget.js (derived from the SLOs, with margin for the small runner) and the relative comparison with main (a 15 % slowdown fails even if the absolute threshold still passes). To keep the gate trusted rather than ignored:

  • Fight noise: fixed arrival rate, warm-up step, the same runner type, and repeat once automatically before failing; a dedicated self-hosted runner reduces variance further.
  • Prefer deterministic budgets where possible: a query-count assertion in an integration test never flakes, and catches the most common regression (N+1).
  • Make failures actionable: upload the summary, link the trace of the slowest request, and say which endpoint regressed by how much.
  • Run the full campaign elsewhere: the PR gate is a smoke alarm; the load, spike and breakpoint tests run nightly or before big events in the performance environment.

In the BoxOffice code: the performance budget gate (lab 11)

Hands-on 11 β€” Performance gate builds a smaller version of this gate, on purpose: one endpoint (GET /catalog/events/{id}), a 20 s warm-up scenario that the thresholds ignore, then 40 s at 50 req/s in the open model, with absolute thresholds only β€” p95 < 300 ms, p99 < 800 ms, failed < 1 %, checks > 99 %. No baseline comparison yet: the budget is generous (the lab measured p95 β‰ˆ 7 ms on a laptop) so that only real regressions fail, because a gate that fails randomly trains people to re-run until green. It runs as the performance budget job of ci.yaml, and the image job needs it: a change that breaks the budget is never built, scanned or deployed. The lab proves it by adding a 400 ms Thread.sleep to the event endpoint and watching p(95)<300 turn red.

load/budget.js (warm-up, budget, thresholds)
export const options = {
  scenarios: {
    warmup: { // JIT, connection pools and cache: not measured
      executor: 'constant-arrival-rate',
      rate: 20,
      timeUnit: '1s',
      duration: '20s',
      preAllocatedVUs: 20,
    },
    budget: { // the measured part: an open-model load the service must sustain
      executor: 'constant-arrival-rate',
      startTime: '20s',
      rate: Number(__ENV.RATE || 50),
      timeUnit: '1s',
      duration: '40s',
      preAllocatedVUs: 50,
      maxVUs: 200,
    },
  },
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
  thresholds: {
    'http_req_duration{scenario:budget}': ['p(95)<300', 'p(99)<800'],
    'http_req_failed{scenario:budget}': ['rate<0.01'],
    'checks{scenario:budget}': ['rate>0.99'],
  },
};
.github/workflows/ci.yaml (the performance budget job)
performance:
  name: performance budget
  needs: test
  runs-on: ubuntu-latest
  timeout-minutes: 20
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    - name: Start PostgreSQL, Redis and catalog
      run: docker compose -f .github/ci/compose.perf.yaml up -d --build --wait --wait-timeout 300
    - uses: grafana/setup-k6-action@43b9fc21641a76002687994433dd586f56e791b1 # v1.2.2
      with:
        k6-version: "1.8.2"
    - name: k6 budget (fails the job when a threshold is crossed)
      uses: grafana/run-k6-action@4082a08c7e40cf6d65863911077dfb60ae0a4f04 # v1.5.0
      env:
        BASE_URL: http://localhost:8082
      with:
        path: load/budget.js

In the interview

Close the capacity part of a design with an explicit calculation: "Peak 4 500 holds/s; one pod sustains ~275 within the SLO; at 75 % of the knee that is 22 pods, 33 to survive a zone loss; connections by Little's Law are 4 500 Γ— 6 ms β‰ˆ 27 active, so PgBouncer in front of the primary; autoscaling takes minutes, so we pre-scale at 09:45 and the waiting room admits at most what the database can take." Few candidates connect measured knee β†’ replicas β†’ headroom β†’ admission control; doing it in one breath is a strong signal.

Best practices

  • Keep the capacity model as code and re-run it at every capacity review β€” demand, knee per replica and headroom as inputs; a spreadsheet nobody re-runs becomes last quarter's plan.
  • Set the waiting room's admission rate from the knee of the narrowest component, and alert when the admitted rate approaches it β€” admission is the one limit you choose; every other limit is discovered.
  • Track cost per unit of demand next to utilization β€” USD per thousand tickets sold shows whether a capacity change was worth it. See Cloud-native β€” FinOps.
  • Set a budget from measured numbers with a wide margin, and tighten it only after watching its trend β€” a gate must fail on regressions, not on runner noise. In BoxOffice: p95 < 300 ms against ~7 ms measured, in load/budget.js of lab 11.
  • Make the budget a required step before the image is built β€” a change that breaks it is never scanned, published or deployed. In BoxOffice: the image job needs: [test, performance] in ci.yaml (lab 11).

Pitfalls

  • Planning with the maximum throughput instead of the knee: the system "handles" that rate with a broken SLO.
  • Forgetting shared resources: 30 more pods are 300 more database connections.
  • Relying on reactive autoscaling for a known spike: the lag is longer than the spike's critical minutes.
  • Headroom that disappears in a failure: compute capacity after losing a zone, not before.
  • A flaky performance gate: if it fails randomly, people learn to re-run it until green β€” reduce noise and prefer deterministic budgets.
  • Never re-measuring: the knee moves with every release; yesterday's capacity plan is a hypothesis.

How it connects

flowchart LR
    KNEE["measured knee per pod<br/>← Load testing"] --> REP["replicas = peak Γ·<br/>(knee Γ— target utilization)"]
    LL["Little's Law<br/>N = X Γ— R"] --> CONN["threads, connections,<br/>PgBouncer"]
    REP --> HEAD["headroom:<br/>zone loss, 75 % of the knee"]
    HEAD --> LAG["autoscaling lag, cold starts<br/>β†’ pre-scale before 10:00"]
    LAG --> WR["waiting room admits what<br/>the database can take"]
    REP --> COST["pods β†’ nodes β†’ dollars<br/>β†’ Cloud-native FinOps"]
    FC["forecast and<br/>capacity review"] --> REP
    BUD["performance budget in CI<br/>guards the knee"] --> KNEE

Palette components: app (replicas per service) Β· sql (connections and the primary's limit) Β· queue (the waiting room's admission rate) Β· serverless (cold starts and concurrency limits) Β· metrics (utilization, the autoscaler's signal). Scenarios: serverless-burst, scale-out, flash-sale. Continues in: Estimation β€” queueing, Compute β€” serverless & autoscaling, Cloud-native β€” Kubernetes, Cloud-native β€” FinOps, Case study β€” ticketing.



  1. LITTLE, J. D. C. A Proof for the Queuing Formula: L = Ξ»W. Operations Research, v. 9, n. 3, p. 383–387, 1961. ↩

  2. ALLSPAW, J.; KEJARIWAL, A. The Art of Capacity Planning: Scaling Web Resources in the Cloud. 2nd ed. O'Reilly, 2017. ↩

  3. BEYER, B. et al. Site Reliability Engineering, ch. 21 "Handling Overload". O'Reilly, 2016. ↩

  4. Grafana Labs. Automated performance testing. k6 documentation. ↩