Intro
Capacity planning for Node.js is the practice of estimating and provisioning the CPU, memory, and concurrency your application needs to meet performance targets under expected load. Unlike traditional threaded servers, Node.js runs JavaScript on a single-threaded event loop, which creates unique bottlenecks and scaling patterns. Getting capacity right prevents slow responses, crashes, and over-provisioned infrastructure.
This guide walks through a practical, version-aware workflow for Node.js capacity planning. You will learn how to inventory your environment, size resources safely, verify performance, handle failure modes, and use an operations checklist. Each section includes concrete commands, expected outputs, and recovery steps, with placeholders you can adapt to your stack.
We assume Node.js 18 or later on Linux (Ubuntu 22.04 LTS in examples) for deterministic behavior. Commands that differ across versions are noted.
Version and Environment Inventory
Before any capacity tuning, capture your Node.js version, operating system, and deployment topology. This baseline is essential for reproducing issues and knowing which flags and APIs are available.
Identify Installed Version and Platform
Run the following read-only commands:
node --version
# v18.19.0 (example)
npm --version
# 10.2.3
uname -a
# Linux app-server-01 5.15.0-91-generic #101-Ubuntu SMP ... x86_64 x86_64 x86_64 GNU/Linux
Check whether your Node.js binary is a release build or a debug build, as debug builds can be much slower:
node -p "process.release"
# { name: 'node', lts: 'Hydrogen', sourceUrl: '...', headersUrl: '...', libUrl: '...' }
If you use a process manager like systemd, PM2, or Docker, record that too. Example for systemd:
systemctl show node-app -p Environment,ExecStart,MemoryMax,CPUQuota
# Environment= NODE_ENV=production PORT=3000
# ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /opt/app/server.js ; ... }
# MemoryMax=infinity
# CPUQuota=infinity
Prerequisites
- Access to the server with sudo or appropriate permissions for system-level inspection.
- Node.js 18+ installed from official packages or nvm.
- Your application code available locally or on the server.
- A load testing tool later (we will use
autocannonfor HTTP benchmarks). - Monitoring tools such as Prometheus and Grafana are recommended but not required.
Blast Radius and Recovery for Inventory
Inventory commands are read-only and do not change system state. There is no blast radius. If a command is not found, install the missing package or use an alternative. For example, uname is part of coreutils and should always exist. No recovery is needed, but record the output in your runbook.
Safe Configuration Path
Sizing Node.js starts with understanding the event loop and then applying limits that match your workload. A safe path is to measure first, set conservative limits, and then adjust one parameter at a time.
Event Loop and Thread Pool Basics
Node.js uses a single-threaded event loop for JavaScript execution. CPU-intensive tasks block the loop and delay all other requests. Some operations (fs, crypto, dns) use a libuv thread pool with a default size of 4. You can inspect the pool size:
node -p "process.env.UV_THREADPOOL_SIZE || 'default (4)'"
If set, the environment variable appears. To change it, set it before starting the process:
UV_THREADPOOL_SIZE=8 node server.js
Blast radius: affects only file system, DNS, and crypto operations. Increase only if profiling shows thread pool saturation. Recovery: unset the variable or set back to 4.
Enforce Memory Limits
Node.js has a default max old space size of about 2 GB on 64-bit systems. You can set a limit with --max-old-space-size (in MB). For a server with 4 GB RAM running only Node, you might allocate 3 GB to the heap:
node --max-old-space-size=3072 server.js
Verify the limit inside the app:
console.log(v8.getHeapStatistics().heap_size_limit);
// 3221225472 (3 GB)
Blast radius: only this process. If the limit is too low, the app crashes with JavaScript heap out of memory. Recovery: restart with a higher limit or fix memory leaks.
Set Environment-Specific Configuration
Use environment variables for tunables like port, database pool size, and timeouts. Example .env file (committed to secrets manager, not version control):
PORT=3000
DB_POOL_MAX=20
HTTP_TIMEOUT_MS=5000
Load and validate with dotenv (or Node 20's built-in --env-file):
node --env-file=.env server.js
Inside app:
const port = parseInt(process.env.PORT, 10);
if (!port || port < 1 || port > 65535) {
throw new Error(`Invalid PORT: ${process.env.PORT}`);
}
Blast radius: affects all incoming connections. Recovery: rollback the env var change and restart.
Choose a Process Manager
For production, use systemd, PM2, or Docker to manage restarts and resource limits. Example systemd unit with limits:
[Service]
ExecStart=/usr/bin/node /opt/app/server.js
Restart=on-failure
MemoryMax=3G
CPUQuota=200%
Environment=NODE_ENV=production PORT=3000
Verify after reload:
sudo systemctl daemon-reload
sudo systemctl restart node-app
systemctl show node-app -p MemoryMax,CPUQuota
# MemoryMax=3221225472
# CPUQuota=200%
Blast radius: entire service. Recovery: revert unit file and restart.
Verification and Diagnostics
Capacity planning is not complete without load testing to verify that your chosen limits and topology handle expected traffic. Use realistic workloads and measure latency, throughput, and resource usage.
Baseline Measurement
First, measure your application's performance without artificial limits. Use autocannon to load test a simple HTTP endpoint. Install it globally or use npx:
npm install -g autocannon
Start your server (e.g., on port 3000). Then run a baseline test with 100 connections for 30 seconds:
autocannon -c 100 -d 30 http://localhost:3000/api/health
Example output (truncated):
Running 30s test @ http://localhost:3000/api/health
100 connections
Stat Avg Stdev Max
Latency (ms) 12.34 5.67 45.67
Req/Sec 8234.56 123.45 8567.89
Bytes/Sec 2.1 MB
Requests per second: 8234.56
Record this as your baseline. If the endpoint is CPU-light, you may see thousands of requests per second.
Test with a CPU-Bound Endpoint
Create a temporary endpoint that performs a CPU-intensive calculation (e.g., Fibonacci) to see event loop blocking:
app.get('/cpu', (req, res) => {
const n = 40;
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
res.json({ fib: fib(n) });
});
Run the same load test. You will see p99 latency spike to seconds and throughput drop to single digits. This demonstrates why CPU work must be offloaded (worker threads, separate service, or optimized algorithm).
Measure Resource Usage
During load test, monitor CPU, memory, and event loop lag. Use pidstat (from sysstat) and clinic doctor or node --prof.
pidstat example:
pidstat -p $(pgrep -f 'node server.js') 1
# Linux ...
# 09:00:01 UID PID %usr %system %guest %CPU CPU minflt/s majflt/s VSZ RSS %MEM
# 09:00:02 1000 1234 45.0 5.0 0.0 50.0 0 0.0 0.0 700000 120000 3.0
For event loop lag, use clinic doctor:
npx clinic doctor --autocannon [ -c 100 -d 30 http://localhost:3000 ] -- node server.js
This produces an HTML report showing event loop utilization and gaps.
Verify Cluster Scaling
If using the cluster module, verify that workers are balanced. Example server.js with cluster:
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').availableParallelism();
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(3000);
console.log(`Worker ${process.pid} started`);
}
Run the server and check worker processes:
pgrep -af 'node server.js'
# 4567 node server.js (primary)
# 4568 /usr/bin/node server.js (worker 1)
# 4569 /usr/bin/node server.js (worker 2)
# ...
Load test again; you should see higher total throughput and lower latency under heavy load compared to single process.
Failure Modes and Recovery
Understanding how Node.js fails under resource pressure helps you design recovery procedures.
Out of Memory (OOM)
Symptom: Process exits with code 134 or 137, log shows FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory.
Diagnosis: Check system logs (journalctl -u node-app -n 50) and use heap snapshots before crash if possible.
Recovery:
- Increase
--max-old-space-sizeif the limit is artificial and server has free RAM. - Fix memory leak (e.g., use
--inspectand Chrome DevTools heap profiler). - Add more swap as temporary relief, but do not rely on it.
- Restart service:
sudo systemctl restart node-app.
Event Loop Blockage
Symptom: High latency, health checks time out, but CPU usage may be low if blocked on I/O.
Diagnosis: Use clinic doctor or monitor event loop lag via process.hrtime() sampling:
setInterval(() => {
const start = process.hrtime.bigint();
setImmediate(() => {
const lag = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`Event loop lag: ${lag.toFixed(2)} ms`);
});
}, 1000);
Recovery:
- Offload CPU tasks to worker threads or a job queue.
- Optimize blocking code (e.g., replace sync fs calls with async).
- Scale horizontally with cluster or load balancer.
Port Exhaustion
Symptom: Outgoing requests fail with EADDRNOTAVAIL or EADDRINUSE when using many external services.
Diagnosis: Check number of sockets in TIME_WAIT: ss -s.
Recovery:
- Reuse connections with keep-alive agents.
- Increase ephemeral port range and reduce TIME_WAIT (Linux:
net.ipv4.ip_local_port_rangeandnet.ipv4.tcp_tw_reuse). - Reduce connection churn.
Disk Full from Logs
Symptom: Application crashes or writes fail. Check df -h.
Recovery:
- Rotate logs with logrotate or a logging library.
- Offload logs to a central system.
- Delete old logs:
sudo find /var/log/myapp -name '*.log' -mtime +7 -delete.
Operations Checklist
Use this checklist for routine capacity reviews and before major changes. Replace the bracketed examples with your actual values.
- [ ] Inventory: Run
node --version,uname -a, and record process manager settings. Example output stored in runbook. - [ ] Baseline load test: Run
autocannon -c 100 -d 30 http://localhost:3000/healthand record p95 latency and RPS. Example: p95=25ms, RPS=8000. - [ ] Check memory limit: Confirm
v8.getHeapStatistics().heap_size_limitmatches intended value. Example: 3221225472 bytes. - [ ] Review event loop lag: Ensure lag stays below 50ms in normal operations. Example: average lag 5ms.
- [ ] Verify cluster workers: For multi-core setup, ensure one worker per CPU. Example: 8 workers on 8-core machine.
- [ ] Test failure recovery: Simulate OOM by starting with low memory limit and verify systemd restarts. Example:
node --max-old-space-size=64 server.jsshould crash and restart automatically. - [ ] Check monitoring alerts: Ensure alerts are set for CPU > 80%, memory > 80%, event loop lag > 100ms, and error rate > 1%.
- [ ] Review dependencies: Verify database connection pool sizes and timeouts match capacity. Example: PostgreSQL pool max 20, timeout 5s.
When any check fails, document the failure and take corrective action before proceeding with capacity changes.
Conclusion
Node.js capacity planning is an iterative process of measuring, setting limits, and verifying performance under load. By following the version-specific inventory, safe configuration, verification, and recovery steps in this guide, you can avoid common pitfalls like event loop blocking and out-of-memory crashes.
Start with a low-risk verification: run a baseline load test on a staging environment, record the metrics, and compare against your production targets. Then gradually apply resource limits and scaling strategies, one change at a time, always with a rollback plan. Integrate the operations checklist into your release process to make capacity planning a regular discipline rather than a reaction to incidents.
With the right commands and diagnostics, you can confidently size your Node.js application for current and future demand.