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.
- name
capacity-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
- You need to design and run the load test itself — use load-testing
- You are reviewing an existing bill for waste — use cloud-cost-audit
- You are reviewing a whole architecture proposal — use system-design-review
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.
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_averagefrom 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
| Resource | Target at forecast peak | Why |
|---|---|---|
| Stateless app CPU | 50 to 70% | Absorb spikes and autoscaling lag |
| Database primary CPU | 40 to 60% | Hard to scale quickly; failover target must cope |
| Database connections | 70 to 80% of max minus reserved | Leave room for admin, migrations, failover reconnect storms |
| Disk | 70 to 80% | Growth surprises, compaction, vacuum, snapshots |
| Queue workers | 60 to 75% busy | Backlog drains after spikes |
| Memory | 70 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
| Resource | Formula | Common limit |
|---|---|---|
| App instances | ceil(peak_rps / (rps_per_instance_at_slo x target_util)), x N/(N-1) for zone loss | Cluster size, quotas |
| DB connections | instances x pool_size (+ workers, cron, migrations) | max_connections (use a pooler past a few hundred) |
| DB writes | peak_rps x write_ratio x writes_per_request | Measured sustainable write rate on the primary |
| Queue workers (concurrent slots) | peak_job_rate x seconds_per_job / target_util | Downstream rate limits |
| Storage | rows_per_day x bytes_per_row x (1 + index_overhead) x replication, accumulated, capped by retention | Volume size, backup window |
| Network egress | peak_rps x avg_response_bytes | NIC limits, NAT gateway throughput, egress cost |
| Third-party APIs | calls per request x peak rps | Vendor rate limits and quotas |
| Cloud quotas | instances, IPs, load balancer rules | Raised 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:
| Action | Typical lead time |
|---|---|
| Scale out stateless service | Minutes to hours (if quotas allow) |
| Raise cloud quotas | Days |
| Bigger database instance (with failover) | Days to plan, minutes to hours to execute |
| Add a connection pooler | 1 to 2 weeks with testing |
| Read replicas and read routing | 2 to 4 weeks |
| Partitioning or sharding | Months |
| Hardware or reserved capacity purchase | Weeks 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
- Every input in the model has a source: measurement (with date and query) or a named assumption.
- The per-instance number comes from a load test that held the SLO; the saturating resource is recorded.
- Back-test: run the model for last quarter’s horizon from data available then; it should land within about 20% of actual peaks.
- 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
#!/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.