Intro
Express API capacity planning is about turning observed behavior into predictable performance under load. This guide shows how to inventory your environment, choose the smallest safe change, verify outcomes with concrete commands, and recover if expectations are not met. It is written for developers, DevOps engineers, and startup teams running Express in production.
We will connect scaling choices (process count, CPU/memory, connection pools) to measurable signals (RPS, latency percentiles, error rates, event loop lag) with practical examples. The operating principle is simple: observe first, change one scoped thing, verify, and keep a clear recovery path.
Version and Environment Inventory
Goal: name the exact component under test, its version, where it runs, and what resources it has before you change anything. Keep this read-only.
- Component: Express HTTP API (Node.js runtime)
- Typical dependencies: OS/container/Kubernetes, load balancer, database/cache, message broker
- Outputs to capture: version, topology, CPU/memory, instance count, baseline RPS/latency, error rates
Prerequisites
- Shell access to the host/container or Kubernetes namespace
- Ability to run read-only commands; do not expose secrets
Read-only inventory commands (run what applies to your setup)
# Versions
node -v
npm -v
npm ls express --depth=0 # shows installed Express version
# Host resources (Linux)
uname -a
lscpu | egrep 'Model name|CPU\(s\)'
free -h
# Container/Kubernetes topology
docker ps --filter "name=<SERVICE_NAME>"
docker stats <CONTAINER_ID_OR_NAME>
kubectl -n <NAMESPACE> get deploy <DEPLOYMENT_NAME> -o wide
kubectl -n <NAMESPACE> get pods -l app=<APP_LABEL> -o wide
kubectl -n <NAMESPACE> top pods -l app=<APP_LABEL>
# External reachability and basic health
curl -sS -o /dev/null -w "%{http_code}\n" http://<HOST_OR_LB>/healthz
Baseline performance snapshot (non-invasive)
- If you already expose metrics (e.g., Prometheus), capture: requests_total, request_duration_seconds p50/p95/p99, error_ratio, CPU %, memory RSS, GC pauses, event loop lag.
- If you do not have metrics yet, note current average response latency from your load balancer logs and current instance count.
Define expectations before interventions
- Target SLO example: p95 latency <= 200 ms at 500 RPS, error rate < 1%, CPU < 75% sustained, event loop p90 lag < 30 ms.
- Failure signals example: p95 latency > target for >5 minutes, error spikes (429/500/503), OOM kills, DB pool exhaustion.
Data hygiene
- Never print or paste secrets. Mask connection strings and identifiers with placeholders like <PG_URI>, <SERVICE_NAME>, <NAMESPACE>.
Safe Configuration Path
Goal: change one thing at a time with a known blast radius and a tested rollback. Below are common, low-risk changes tied to Express capacity.
- Use multiple worker processes (one per CPU core)
- When: single Node.js process is CPU-bound or event loop lag increases with load.
- Prerequisites: stateless handlers or sticky sessions at the load balancer if you use in-memory session state.
- Minimal change with PM2:
pm2 start app.js -i max --name <SERVICE_NAME> # one worker per CPU core
pm2 list
- Verification: RPS increases ~linearly with cores for CPU-bound routes; per-worker CPU ~60–75% under target load; latency percentiles stable.
- Recovery:
pm2 delete <SERVICE_NAME>and start previous process model, orpm2 scale <SERVICE_NAME> <PREVIOUS_COUNT>.
Alternative with Node.js cluster (code change required)
const cluster = require('node:cluster');
const os = require('node:os');
if (cluster.isPrimary) {
const num = Number(process.env.WORKERS || os.cpus().length);
for (let i = 0; i < num; i++) cluster.fork();
} else {
// start your Express app here
}
- Set safe HTTP server timeouts and keep-alives
- When: many short requests or connection churn; or slow clients pin sockets.
- Minimal change (in your server bootstrap):
const server = app.listen(process.env.PORT || 3000, () => {
console.log('listening');
});
server.keepAliveTimeout = 60000; // 60s persistent connections
server.headersTimeout = 65000; // must be > keepAliveTimeout
server.requestTimeout = 30000; // 30s max per request (tune per endpoint)
- Blast radius: affects connection reuse and slow clients; coordinate with upstream LB timeouts.
- Verification: socket count stabilizes, fewer TIME_WAIT/ESTABLISHED spikes; latency unaffected or improved.
- Recovery: restore previous timeout values and reload.
- Right-size database connection pools
- When: DB is the bottleneck or you see pool exhaustion timeouts.
- Minimal change (PostgreSQL example using
pg):
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.PG_URI, // <PG_URI>
max: Number(process.env.PG_POOL_MAX || 20),
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
- Guidance: start with pool size = min(2 x CPU_cores, DB_permitted_pool / instance_count). Too large pools reduce DB throughput.
- Verification: reduced
ETIMEDOUT/pool wait; DB CPU < 75%; query latency drops. - Recovery: revert
PG_POOL_MAXand reload app.
- Add a simple concurrency gate to protect backends
- When: burst traffic overwhelms DB or an upstream service.
- Minimal, code-only gate (small blast radius):
let inFlight = 0;
const MAX_IN_FLIGHT = Number(process.env.MAX_IN_FLIGHT || 100);
app.use((req, res, next) => {
if (inFlight >= MAX_IN_FLIGHT) return res.status(503).send('busy');
inFlight++;
res.on('finish', () => { inFlight--; });
next();
});
- Verification: error rate may increase temporarily (503), but p95 latency and DB saturation improve; queueing occurs at edge/LB instead of inside the app.
- Recovery: remove middleware or lower
MAX_IN_FLIGHT.
- Set process memory limits explicitly
- When: GC pauses or OOM kills under load.
- Minimal change (startup flag):
node --max-old-space-size=1024 app.js # ~1 GB heap, tune per container memory
- Verification: RSS remains below container limit; fewer GC stalls.
- Recovery: revert to previous value and restart.
Verification and Diagnostics
Goal: confirm that a change improved capacity and did not regress stability. Measure before/after with the same method.
Non-disruptive checks
- CPU/memory:
top,htop,docker stats,kubectl top pods - Socket pressure (Linux):
ss -sandss -tan state established | wc -l - Log-derived latency and error rates from your LB or API gateway
Load testing (pre-production or controlled window only)
- Autocannon example:
npx autocannon -c 50 -d 60 -p 10 http://<HOST_OR_LB>/api/<ENDPOINT>
- wrk example:
wrk -t4 -c200 -d60s http://<HOST_OR_LB>/api/<ENDPOINT>
- Compare: RPS, p50/p95/p99 latency, non-2xx/5xx rate. Hold everything else constant between runs.
Event loop lag (adds minimal instrumentation)
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log(`eventLoopLag_p90_ms=${(h.percentile(90) / 1e6).toFixed(1)}`);
}, 10000);
- Expectation: p90 < 30 ms for healthy APIs; spikes suggest synchronous CPU work or GC pressure.
Simple debug stats endpoint (protect in production)
app.get('/debug/stats', (req, res) => {
const mu = process.memoryUsage();
res.json({
pid: process.pid,
rss: mu.rss,
heapUsed: mu.heapUsed,
eventLoopLag_p90_ms: Number((h.percentile(90) / 1e6).toFixed(1))
});
});
- Verification: confirms the process-level footprint under load and helps correlate with latency.
Capacity math (quick check)
- Estimate concurrency with Little's Law: Concurrent = RPS x AvgLatencySeconds. Example: 500 RPS x 0.1 s = 50 in-flight.
- If each request uses ~1 DB connection and your pool max is 20, expect queuing unless you add workers or raise the pool (only if DB can handle it).
Failure Modes and Recovery
Common symptoms and what to do:
- 503 Service Unavailable bursts under load
- Cause: concurrency spikes beyond worker or DB capacity
- Actions: increase workers (
pm2 scale <SERVICE_NAME> <N>), add concurrency gate (see above), confirm LB retries and backoff - Recovery: scale back to last stable N; verify
p95returns to baseline
- 500 errors with
ECONNRESETorETIMEDOUT - Cause: upstream dependency timeouts or server timeouts too aggressive
- Actions: align server.requestTimeout with dependency SLAs; ensure DB connection timeout < HTTP timeout; tune retry policy at client/LB
- Recovery: restore previous timeouts and retest
- Memory spikes and OOM kills
- Cause: large payloads, unbounded caches, memory leaks
- Actions: set
--max-old-space-size, cap payloads (express.json({ limit: '1mb' })), profile allocations off-peak - Recovery: lower traffic (reduce replicas behind LB), restart workers gracefully (
pm2 reload <SERVICE_NAME>), then roll forward with limits
- Too many open files/sockets (EMFILE)
- Cause: connection explosion or small
ulimit - Actions: increase soft limit for the service user and container; ensure keep-alive is enabled and reuse connections
- Recovery: temporarily reduce connections (lower load), then raise limits; example for a shell session:
ulimit -n 65535(make persistent via service config)
- Deployment-induced thundering herd
- Cause: all pods/processes restart at once
- Actions: use rolling restarts (
pm2 reloador Kubernetes rolling update with surge/unavailable limits) - Recovery: undo rollout
kubectl -n <NAMESPACE> rollout status deploy/<DEPLOYMENT_NAME>
kubectl -n <NAMESPACE> rollout undo deploy/<DEPLOYMENT_NAME>
Operations Checklist
Use this before each capacity change:
- Identify
- Record timestamp, Node and Express versions, instance count, CPU/memory per instance
- Note current SLOs and baseline: RPS, p95/p99 latency, error rate, CPU %, memory RSS, event loop lag
- Observe (read-only)
- Confirm healthz responds 200
- Capture
docker statsorkubectl top pods - Snapshot sockets with
ss -s
- Plan one change
- Pick minimal change (workers, pool size, timeouts, memory cap, concurrency gate)
- Define blast radius and exact rollback command
- Define expected improvement and failure signal in advance
- Apply and verify
- Apply change (e.g.,
pm2 scale <SERVICE_NAME> <N>, env var update + restart) - Run the same load test command or observe real traffic for a fixed window
- Compare metrics side by side; keep notes
- Decide and document
- If improved, keep and document the new standard with version scope
- If degraded, execute rollback immediately and log the failure mode
Conclusion
Capacity planning for an Express API becomes reliable when every step is version-scoped, observable, and reversible. Start with an inventory: confirm Node/Express versions, topology, and current performance. Apply the smallest safe change (workers, timeouts, pool sizes, concurrency gates, memory caps) and verify with repeatable measurements: RPS, latency percentiles, error rates, CPU/memory, event loop lag. If the expected signal does not appear, roll back, record what happened, and try the next smallest change.
Choose one low-risk verification today: capture your baseline, run a short load test against a single hot endpoint, and confirm you can safely scale workers up and down. A dependable operational loop makes failure visible, protects sensitive values, limits blast radius, and rehearses recovery before an incident demands it.