Architecture No. 314

Capacity Planning skill for Claude Code

Know which resource runs out first, and when. Claude turns business drivers into peak requests per second, takes per-instance capacity from a load test at the latency SLO instead of CPU guesses, and applies utilization targets plus zone-loss headroom. A bundled script projects instances, connections, writes, workers, storage and cost over months, flags the first resource to run out, and the plan lists actions with lead times and decision dates.

  • namecapacity-planning
  • size174 lines · ≈2,050 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Estimates the load a system must handle and the resources it needs over time - traffic models from users to peak requests per second, Little's law for concurrency, per-instance capacity measured at the latency SLO, utilization targets and zone-failure headroom, database connection and write budgets, queue worker counts, storage growth with indexes, replication and retention, cost forecasts, and lead times, with a projection script and a plan template. Use when sizing a new service or launch, forecasting when current infrastructure runs out, preparing for a traffic event, or justifying an infrastructure budget.

Try asking

  • We expect 3x users by next year. When does our current setup run out?
  • Size the infrastructure for a new API that launches with 50k daily users
  • Build a capacity plan for Black Friday with cost estimates

Use it when

  • Sizing a new service or a launch
  • Forecasting when current infrastructure hits a limit
  • Preparing for a known traffic event
  • Justifying or forecasting an infrastructure budget

Skip it when

How to check the result

Every model input cites a measurement or named assumption, the per-instance figure comes from a load test that held the SLO, and back-testing last quarter lands within about 20% of actual peaks.

The common mistake it prevents

Sizing from average daily traffic and CPU percentages, then discovering at peak that database connections or a downstream rate limit run out long before CPU does.

~/.claude/skills/capacity-planning/SKILL.md

--- name: capacity-planning description: Estimates the load a system must handle and the resources it needs over time - traffic models from users to peak requests per second,… license: MIT ---

Done well means: every number traces to a measurement or a stated assumption, capacity per instance comes from a load test at the latency SLO (not from CPU guesses), the plan survives losing a zone at peak, each bottleneck has a date when it runs out, and the actions needed before that date account for procurement and migration lead times.

Workflow

- [ ] 1. Define the demand model: drivers, peak shape, growth, events
- [ ] 2. Measure per-unit capacity at the SLO (load test)
- [ ] 3. Choose utilization targets and failure headroom
- [ ] 4. Project every resource: compute, DB, queues, storage, network, quotas
- [ ] 5. Find the first bottleneck and its run-out date
- [ ] 6. Forecast cost; list actions with lead times
- [ ] 7. Write the plan; schedule the re-forecast

1. Demand model

Pick the business driver that load follows (DAU, orders, devices, API customers) and convert it to requests:

avg_rps   = DAU x requests_per_user_per_day / 86,400
peak_rps  = avg_rps x peak_to_average            (measure it; 2-4x is common for consumer daily cycles)
future    = today x (1 + monthly_growth)^months
event     = peak_rps x event_multiplier           (launches, sales, sports: get this from the business)
  • Measure peak_to_average from the busiest hour (or minute, for bursty APIs) over the last 4 to 8 weeks of real traffic, not from a daily average.
  • Separate traffic classes with different costs (reads, writes, search, uploads, batch jobs). One “rps” number hides the expensive 5%.
  • Write down the growth assumption and its source (business forecast, trailing 6-month trend). Plan for the forecast and check the plan against 1.5x the forecast.

Little’s law for concurrency: in_flight = arrival_rate x time_in_system. 2,000 req/s at 150 ms average means about 300 concurrent requests across the fleet; at 1.5 s during a slow-dependency incident it means 3,000, which is what pools and limits must survive.

2. Per-unit capacity at the SLO

The capacity of an instance is the highest throughput at which it still meets the latency SLO (for example p99 at or below 300 ms) with an error rate below target, not the throughput at 100% CPU.

  • Run an arrival-rate (open model) load test that ramps until p99 crosses the SLO; record that throughput as rps_per_instance_at_slo. Use load-testing for test design.
  • Test one instance with production-like data size, cache warmth, and dependency latency.
  • Record which resource saturated first (CPU, memory, connections, threads, a downstream limit). That resource is what scales.
  • For databases, measure sustainable writes per second and query mix on production-size data; replica lag under that load is part of the result.

3. Utilization targets and headroom

ResourceTarget at forecast peakWhy
Stateless app CPU50 to 70%Absorb spikes and autoscaling lag
Database primary CPU40 to 60%Hard to scale quickly; failover target must cope
Database connections70 to 80% of max minus reservedLeave room for admin, migrations, failover reconnect storms
Disk70 to 80%Growth surprises, compaction, vacuum, snapshots
Queue workers60 to 75% busyBacklog drains after spikes
Memory70 to 80%GC and fragmentation; OOM kills are abrupt

Failure headroom: with N zones, surviving one zone loss at peak means each zone carries 1/(N-1) of peak after failover. With 3 zones, provision 1.5x the no-failure count. Also size for one instance lost during deploys (rolling update surge settings).

Autoscaling does not remove the need for this: it reacts in minutes, has maximums, depends on quotas, and cannot scale the database.

4. Project every resource

ResourceFormulaCommon limit
App instancesceil(peak_rps / (rps_per_instance_at_slo x target_util)), x N/(N-1) for zone lossCluster size, quotas
DB connectionsinstances x pool_size (+ workers, cron, migrations)max_connections (use a pooler past a few hundred)
DB writespeak_rps x write_ratio x writes_per_requestMeasured sustainable write rate on the primary
Queue workers (concurrent slots)peak_job_rate x seconds_per_job / target_utilDownstream rate limits
Storagerows_per_day x bytes_per_row x (1 + index_overhead) x replication, accumulated, capped by retentionVolume size, backup window
Network egresspeak_rps x avg_response_bytesNIC limits, NAT gateway throughput, egress cost
Third-party APIscalls per request x peak rpsVendor rate limits and quotas
Cloud quotasinstances, IPs, load balancer rulesRaised by ticket; days of lead time

Run the bundled projection on a JSON model:

python3 scripts/capacity_model.py --example > capacity-model.json   # edit with your numbers
python3 scripts/capacity_model.py capacity-model.json

It prints, per horizon (for example 0, 6, 12, 24 months): average and peak RPS, instances with zone-loss headroom, database connections and peak writes, worker slots, storage, and monthly cost, and flags any horizon that exceeds connection, write or storage limits (exit 1). Keep the model file in the repo next to the plan so the forecast can be re-run.

5. First bottleneck and run-out date

For each resource, find the month when demand crosses its limit at the target utilization. Sort by date. The earliest is the plan’s focus. Typical order in practice: database connections, then database write capacity or storage, then everything else.

6. Cost and lead times

  • Cost per horizon: instances x unit price, plus database tier, storage per GB-month, egress, and managed services. Express unit cost too (cost per 1,000 requests or per active user) so finance can compare against revenue drivers.
  • Commitments (reserved instances, savings plans) cover the baseline that is near-certain; keep growth and event capacity on demand.
  • Lead times to subtract from each run-out date:
ActionTypical lead time
Scale out stateless serviceMinutes to hours (if quotas allow)
Raise cloud quotasDays
Bigger database instance (with failover)Days to plan, minutes to hours to execute
Add a connection pooler1 to 2 weeks with testing
Read replicas and read routing2 to 4 weeks
Partitioning or shardingMonths
Hardware or reserved capacity purchaseWeeks to months

7. Re-forecast

Capacity plans expire. Re-run the model monthly or quarterly with fresh measurements and compare forecast against actuals; if the error exceeds about 20%, revisit the growth assumption before the numbers.

Gotchas

  • Using average traffic: systems fail at peak minutes, not daily means.
  • Capacity from CPU math instead of a load test: the real limit is often connections, locks, a downstream rate limit, or GC pauses.
  • Forgetting non-request load: batch jobs, backfills, analytics queries, backups and vacuum compete for the same database.
  • Testing with an empty database: index size and cache hit ratio change with data volume.
  • Assuming linear scaling: shared dependencies (database, cache, locks) flatten the curve; measure at 2 and 4 instances before extrapolating.
  • Ignoring retry amplification: during incidents load can double from retries alone.

Verification

  1. Every input in the model has a source: measurement (with date and query) or a named assumption.
  2. The per-instance number comes from a load test that held the SLO; the saturating resource is recorded.
  3. Back-test: run the model for last quarter’s horizon from data available then; it should land within about 20% of actual peaks.
  4. A failure drill (drain one zone or a third of instances at peak in staging) keeps p99 within the SLO.

Output contract

## Demand model
Driver, current peak, peak-to-average, growth assumption (source), known events
## Measured capacity
rps per instance at SLO, saturating resource, test date and setup
## Projection
(table from capacity_model.py per horizon)
## Bottlenecks
| Resource | Limit | Run-out month | Lead time | Action | Owner | Decide by |
## Cost forecast
Monthly cost per horizon, unit cost, commitment recommendation
## Assumptions and re-forecast date

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/capacity_model.py 153 lines · 6.8 KB
#!/usr/bin/env python3
"""Project traffic, instances, connections, workers, storage and cost from a JSON model.

Usage:
    python3 scripts/capacity_model.py model.json [--example]

--example prints a complete sample model to start from. Every section except
"traffic" and "service" is optional.

Model fields (units in names):
  traffic:  dau, requests_per_user_per_day, peak_to_average, monthly_growth_pct
            (or avg_rps instead of dau and requests_per_user_per_day)
  service:  rps_per_instance_at_slo (measured by load test at the latency SLO),
            target_utilization (0-1), min_instances, zones, survive_zone_loss,
            instance_cost_month
  database: max_connections, reserved_connections, pool_per_instance,
            write_ratio, max_writes_per_sec (measured), max_write_utilization
  queue:    jobs_per_request, seconds_per_job, worker_utilization, worker_cost_month
  storage:  current_gb, rows_per_day_now, bytes_per_row, index_overhead,
            replication_factor, retention_days, volume_gb
  horizons_months: e.g. [0, 6, 12, 24]

Exit codes: 0 = all horizons fit, 1 = at least one limit exceeded, 2 = bad input.
"""
import json
import math
import sys

EXAMPLE = {
    "traffic": {"dau": 1_200_000, "requests_per_user_per_day": 60, "peak_to_average": 3.0, "monthly_growth_pct": 5},
    "service": {"rps_per_instance_at_slo": 400, "target_utilization": 0.6, "min_instances": 3,
                "zones": 3, "survive_zone_loss": True, "instance_cost_month": 150},
    "database": {"max_connections": 800, "reserved_connections": 50, "pool_per_instance": 10,
                 "write_ratio": 0.12, "max_writes_per_sec": 6000, "max_write_utilization": 0.7},
    "queue": {"jobs_per_request": 0.05, "seconds_per_job": 0.5, "worker_utilization": 0.7, "worker_cost_month": 60},
    "storage": {"current_gb": 900, "rows_per_day_now": 4_000_000, "bytes_per_row": 700, "index_overhead": 0.6,
                "replication_factor": 2, "retention_days": 730, "volume_gb": 4000},
    "horizons_months": [0, 6, 12, 24],
}


def need(section, key, kind=float):
    if key not in section:
        raise KeyError(key)
    return kind(section[key])


def project(model, months):
    t, s = model["traffic"], model["service"]
    growth = (1 + float(t.get("monthly_growth_pct", 0)) / 100) ** months
    if "avg_rps" in t:
        avg_rps = float(t["avg_rps"]) * growth
    else:
        avg_rps = need(t, "dau") * need(t, "requests_per_user_per_day") / 86_400 * growth
    peak_rps = avg_rps * need(t, "peak_to_average")

    per_instance = need(s, "rps_per_instance_at_slo") * need(s, "target_utilization")
    base = math.ceil(peak_rps / per_instance)
    zones = int(s.get("zones", 1))
    if s.get("survive_zone_loss") and zones > 1:
        base = math.ceil(base * zones / (zones - 1))
        base = math.ceil(base / zones) * zones           # spread evenly across zones
    instances = max(base, int(s.get("min_instances", 1)))
    row = {"months": months, "avg_rps": avg_rps, "peak_rps": peak_rps, "instances": instances,
           "cost": instances * float(s.get("instance_cost_month", 0)), "issues": []}

    db = model.get("database")
    if db:
        conns = instances * int(db.get("pool_per_instance", 10))
        limit = int(db.get("max_connections", 100)) - int(db.get("reserved_connections", 0))
        row["db_conns"] = conns
        if conns > limit:
            row["issues"].append(f"DB connections {conns} > usable {limit}: add a pooler (PgBouncer) or shrink pools")
        if "max_writes_per_sec" in db:
            writes = peak_rps * float(db.get("write_ratio", 0))
            cap = float(db["max_writes_per_sec"]) * float(db.get("max_write_utilization", 0.7))
            row["db_writes"] = writes
            if writes > cap:
                row["issues"].append(f"peak writes {writes:,.0f}/s > {cap:,.0f}/s allowed on primary")

    q = model.get("queue")
    if q:
        busy = peak_rps * float(q["jobs_per_request"]) * float(q["seconds_per_job"])
        workers = max(1, math.ceil(busy / float(q.get("worker_utilization", 0.7))))
        row["workers"] = workers
        row["cost"] += workers * float(q.get("worker_cost_month", 0))

    st = model.get("storage")
    if st:
        factor = (1 + float(st.get("index_overhead", 0))) * float(st.get("replication_factor", 1))
        rate = (1 + float(t.get("monthly_growth_pct", 0)) / 100)
        gb = float(st.get("current_gb", 0))
        for m in range(months):                          # accumulate month by month at that month's volume
            gb += st["rows_per_day_now"] * rate ** m * 30 * st["bytes_per_row"] * factor / 1e9
        if "retention_days" in st:
            steady = st["rows_per_day_now"] * rate ** months * st["retention_days"] * st["bytes_per_row"] * factor / 1e9
            gb = min(gb, max(steady, float(st.get("current_gb", 0))))
        row["storage_gb"] = gb
        vol = st.get("volume_gb")
        if vol and gb > 0.8 * float(vol):
            row["issues"].append(f"storage {gb:,.0f} GB > 80% of {float(vol):,.0f} GB volume")
    return row


def main(argv):
    if "--example" in argv:
        print(json.dumps(EXAMPLE, indent=2))
        return 0
    if len(argv) < 2:
        print(__doc__)
        return 2
    try:
        with open(argv[1], encoding="utf-8") as fh:
            model = json.load(fh)
        horizons = model.get("horizons_months", [0, 6, 12])
        rows = [project(model, int(h)) for h in horizons]
    except (OSError, json.JSONDecodeError) as exc:
        print(f"error: cannot read model: {exc}", file=sys.stderr)
        return 2
    except KeyError as exc:
        print(f"error: model is missing required field {exc}", file=sys.stderr)
        return 2

    cols = ["months", "avg_rps", "peak_rps", "instances", "db_conns", "db_writes", "workers", "storage_gb", "cost"]
    cols = [c for c in cols if any(c in r for r in rows)]
    print("  ".join(f"{c:>11}" for c in cols))
    for r in rows:
        cells = []
        for c in cols:
            v = r.get(c, "")
            cells.append(f"{v:>11,.0f}" if isinstance(v, (int, float)) else f"{v:>11}")
        print("  ".join(cells))

    s = model["service"]
    print(f"\nassumes {float(s['rps_per_instance_at_slo']):,.0f} rps/instance at SLO, "
          f"{float(s['target_utilization']):.0%} target utilization"
          + (", N+1 zone headroom" if s.get("survive_zone_loss") else "")
          + "; cost column is USD/month for app instances plus workers")

    failed = False
    for r in rows:
        for issue in r["issues"]:
            failed = True
            print(f"LIMIT  month {r['months']}: {issue}")
    if failed:
        print("\nAt least one horizon exceeds a limit; plan the change before that month (minus lead time).")
        return 1
    print("\nOK: all horizons fit within the stated limits.")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc