Use when traffic is growing or about to spike and the system bends under concurrency — deciding what to add and in what order (cache, connection pool, async queue, read replica, more instances) and proving it with a load test against explicit RPS and p95 targets rather than guessing. NOT making one slow request faster or profiling an N+1 (that is `performance`), NOT race-free Redis caches, locks and queue semantics (that is `redis`).
npx skills add https://github.com/ericrisco/rsc-harness --skill scaling
Performance makes one request faster. Scaling makes many requests survive at the same time. Different problem, different toolbox — don't reach for this one when a single endpoint is slow for a single user (that is ../performance/SKILL.md).
The whole job in one line: diagnose the bottleneck, apply the cheapest lever that moves it, re-measure under load. Repeat until the next bottleneck appears or you hit your target.
Prime directive: never add infrastructure without a measurement first. A replica, a queue, or a third app instance you bought on a hunch costs money every month and usually moves the wrong tier. Measure, then add.
Levers are ordered by payoff per dollar. Caching is nearly free and wins biggest; replicas and autoscaling cost forever. Climb the ladder in order; stop the moment the symptom clears.
| Symptom | Likely bottleneck tier | First lever | Sibling that wires it |
|---|---|---|---|
| Slow *only* under load, fine solo | unknown — measure first | USE-method triage (Step 0) | ../monitoring/SKILL.md |
| Same reads recomputed for everyone | app/DB doing repeat work | Lever 1 — cache | ../redis/SKILL.md |
| too many clients already | DB connection slots | Lever 2 — pooler | ../postgresdb/SKILL.md |
| Spiky writes time out / drop | synchronous write path | Lever 2 — async queue | ../redis/SKILL.md |
| Reads dominate, primary CPU hot | DB read capacity | Lever 3 — read replica | ../postgresdb/SKILL.md |
| All tiers healthy, just need throughput | app instance count | Lever 4 — horizontal / autoscale | ../deployment/SKILL.md |
Use the USE method (Utilization, Saturation, Errors) on every resource — CPU, memory, disk, network, and the DB connection pool. For each one ask: how busy (U), how much is queued/waiting (S), and any errors (E).
../monitoring/SKILL.md / observability's job. Scaling *consumes* USE signals; it doesn't build the collectors.Output of Step 0 is one sentence: "the bottleneck is the DB connection pool / app CPU / origin cache-miss rate." Don't proceed without it.
Cache layers, outermost to innermost — each one removes work the layer behind it would have done:
| Layer | Removes | Typical TTL |
|---|---|---|
| CDN / edge | origin round-trip for static + cacheable HTML | minutes–hours |
| HTTP cache headers (Cache-Control, ETag) | re-downloads; enables 304s | per-resource |
| App cache (in-proc / Redis) | recomputed views, serialized payloads | seconds–minutes |
| Query-result cache | repeated identical DB reads | seconds |
../redis/SKILL.md.../performance/SKILL.md before papering over it with TTL.Pool DB connections. Each Postgres connection is a backend process with real memory cost; apps that open a connection per request exhaust max_connections fast.
Bad: app → opens a fresh DB connection per request → "too many clients already"
Good: app → PgBouncer (transaction mode) → small pool of reused server connections
(number_of_pools × default_pool_size) < max_connections − ~15 (leave headroom for superuser/admin slots). Set default_pool_size ≈ 1.5–2× vCores for CPU-bound OLTP — more connections than cores just adds context-switch contention, not throughput.SET / session GUCs, advisory *session* locks, and LISTEN/NOTIFY. Route those to a session-pooling pool or refactor them out. Don't discover this in production.Shed spiky writes into a queue. Queue-based load leveling puts a queue between a bursty producer and a constrained consumer so the consumer drains at its own steady rate; the queue absorbs the spike instead of the synchronous tier melting.
SKIP LOCKED queues) belong to ../redis/SKILL.md and ../postgresdb/SKILL.md. Scaling decides *that* you defer work; those decide it's done correctly.Reach for a replica only when Step 0 says reads dominate and the primary is read-saturated — not as a reflex.
../backups/SKILL.md.primary_conninfo, slots, promotion — is ../postgresdb/SKILL.md. Scaling decides *add a replica and route reads to it*; postgresdb makes it real.../deployment/SKILL.md plus the platform skill (../fly-io/SKILL.md, and siblings for railway/render/vercel). Scaling gives the strategy — how many and triggered by what; the platform gives the knobs.Don't claim the system survives. Measure that it does. k6 (Go core, JS test scripts) reached v1.0.0 on 2025-04-28 under SemVer and is the default OSS load-test tool; the current v1 line is v1.7.x and v2.0.0 shipped in May 2026 (GrafanaCON 2026).
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // ramp up to 50 virtual users
{ duration: '3m', target: 50 }, // hold (steady-state load test)
{ duration: '1m', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // SLO gate: 95% of requests under 500 ms
http_req_failed: ['rate<0.01'], // SLO gate: under 1% errors
},
};
export default function () {
const res = http.get(`${__ENV.TARGET_URL}/api/health`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}
Full ladder (stage configs for each test type), CI gate snippet, and how to read the summary (p95/p99, http_req_failed, spotting the knee) are in references/load-testing-k6.md.
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| Scaling before measuring | You spend on the wrong tier; symptom persists | Step 0 USE triage, name the bottleneck first |
| Scaling a stateful app horizontally | Instances disagree; sessions vanish on routing | Make it stateless, externalize state, then scale |
| Caching cheap work / no TTL strategy | Adds a hop + invalidation bugs for no gain | Cache the expensive read; set deliberate TTLs |
| Load-testing localhost | Measures your laptop, not production | Test a prod-like target over the network |
| Reporting mean latency | Hides the tail users actually feel | Gate on p95/p99 |
| Read replica to absorb writes | Writes still hit one primary; you gain nothing | Replica is read-only; queue/shard writes |
| DB with no connection pooler | too many clients already under any spike | PgBouncer transaction mode + the sizing rule |
| Autoscaling on CPU while DB connections saturate | More instances = more connections = faster DB death | Autoscale on the binding saturation metric |
Scale to the *next* bottleneck, then re-measure — don't pre-buy capacity for traffic you don't have. Every lever has a price: caching is ~free, a pooler is cheap, a read replica and autoscaling cost every month and add operational surface. Climb one rung, re-run the load test, and stop when you clear the target. Survived, proven, no further — that's done.
Take ericrisco/scaling from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.