Intro
REST API capacity planning is the process of predicting and provisioning enough resources—compute, memory, storage, and network—to handle expected traffic while meeting latency and availability targets. It is not a one-time guess but a continuous loop of measuring, testing, and adjusting. This article provides practical examples for developers, DevOps engineers, and technical teams running REST APIs, particularly those built with Node.js, Express, and MongoDB.
Capacity planning is often confused with performance tuning. Capacity planning answers: "How many requests per second can my current setup handle before degrading?" Performance tuning answers: "How do I make one request faster?" Both matter, but this article focuses on the former. We cover concrete techniques: load testing, resource monitoring, horizontal scaling, rate limiting, and failure recovery.
You will learn how to:
- Inventory your current API environment and versions.
- Safely change configuration to improve capacity.
- Verify capacity with load tests and monitoring.
- Identify failure modes and recover from capacity-related incidents.
- Use a repeatable operations checklist.
All examples use placeholders for sensitive data. Replace them with your own environment values. Always run commands in a safe, non-production environment first.
Version and Environment Inventory
Before touching configuration, you must know exactly what you are running. Start by documenting the versions of your runtime, framework, and dependencies. For a typical Node.js API:
node --version
npm --version
Expected output (example):
v20.11.0
10.2.4
For Express, check package.json:
npm list express mongodb
Example output:
[email protected] /home/user/my-api
├── [email protected]
└── [email protected]
Why does this matter? Capacity planning relies on performance characteristics of specific versions. A memory leak fixed in Node.js 20.4 may be present in 18.x. MongoDB driver 6.x has different connection pooling defaults than 5.x. Always pin versions and update deliberately.
Next, map your deployment topology. Are you running a single instance behind a reverse proxy (Nginx, HAProxy), or multiple instances behind a load balancer? How is MongoDB deployed: standalone, replica set, or sharded cluster? Document this in a simple diagram or table.
Example topology table:
| Component | Instance Type | Count | Version |
|---|---|---|---|
| API server | t3.medium (2 vCPU, 4 GB RAM) | 2 | Node.js 20, Express 4.19 |
| Load balancer | AWS ALB | 1 | N/A |
| Database | db.r5.large (2 vCPU, 16 GB RAM) | 3 (replica set) | MongoDB 7.0 |
Finally, capture the current observable state. On Linux, check CPU and memory:
top -bn1 | head -20
On the database, check slow queries and connection pool stats:
// MongoDB shell
db.serverStatus().connections
db.serverStatus().opcounters
This read-only observation creates a baseline. Record these values with timestamps. After any change, compare against the baseline to measure impact.
Safe Configuration Path
A safe configuration change is:
- Small and scoped to one parameter.
- Observable (you can see the before/after effect).
- Reversible (you know how to roll back).
Start with the most common capacity lever: the Node.js event loop and memory limits. By default, Node.js uses a heap size limit based on available system memory. For capacity planning, you might explicitly set it to avoid unexpected garbage collection pauses.
Blast radius: Setting a too-low --max-old-space-size can crash the process. Set it only after measuring current heap usage.
Prerequisite: Node.js 12+ (older versions use different flag syntax).
Command to set heap limit to 4 GB:
node --max-old-space-size=4096 server.js
Expected output: server starts without error. Verify with:
node -e "console.log(v8.getHeapStatistics().heap_size_limit / 1024 / 1024 + ' MB')"
Expected: 4096 MB (approximately).
Recovery: If the process crashes due to insufficient memory, remove the flag or increase the value, then restart.
Next, Express has connection timeout and body size limits that affect capacity. A default body limit of 100kb may be too small for large payloads, causing 413 errors. Increase if needed, but be mindful of memory per request.
Configuration in Express (middleware):
const express = require('express');
const app = express();
app.use(express.json({ limit: '1mb' })); // increase body limit
Blast radius: Larger limits allow more memory per request, which can exhaust heap under high concurrency.
Verification: Send a request with a payload just under 1mb and ensure it succeeds; send one over and expect 413. Use curl:
curl -X POST -H "Content-Type: application/json" -d @large.json http://localhost:3000/api/data
Recovery: Revert to default or lower limit.
For MongoDB, the connection pool size directly impacts capacity. The default pool size in the Node.js driver is 5 (as of v6). For high throughput, you may need more. Configure in your connection string:
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://localhost:27017', {
maxPoolSize: 20,
minPoolSize: 5
});
Blast radius: Too many connections can overwhelm the database. Monitor db.serverStatus().connections to ensure you stay within limits.
Verification: Under load, check serverStatus().connections.current; should not exceed maxPoolSize and not hit database's maxIncomingConnections.
Verification and Diagnostics
Verification means proving your API can handle the expected load before deploying to production. The gold standard is load testing with realistic traffic patterns. Use tools like autocannon (Node.js), wrk, or k6.
Prerequsite: Have a non-production environment that mirrors production in specs.
Install autocannon:
npm install -g autocannon
Run a simple load test:
autocannon -c 100 -d 30 http://localhost:3000/api/health
Flags: -c 100 means 100 concurrent connections, -d 30 means 30 seconds.
Sample output (truncated):
Running 30s test @ http://localhost:3000/api/health
100 connections
┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┬────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┼────────┤
│ Latency │ 1 ms │ 3 ms │ 12 ms │ 20 ms │ 4.5 ms │ 3.1 ms │ 45 ms │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬─────────┬─────────┐
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼─────────┼─────────┤
│ Req/Sec │ 1500 │ 1500 │ 2800 │ 3200 │ 2750.33 │ 400.2 │ 1200 │
└───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴─────────┴─────────┘
Interpretation: At 100 concurrent connections, the API handles about 2750 requests/second with p99 latency 20ms. Is that enough? Compare with your capacity target. If your target is 5000 req/s with p99 < 50ms, you have headroom on latency but need more throughput.
To find the breaking point, increase connections gradually:
autocannon -c 200 -d 30 http://localhost:3000/api/health
Observe when latency spikes or error rate increases. That defines your current single-instance capacity.
Monitoring is equally important. In production, collect metrics from the Node.js process and the database. Use prom-client for Prometheus metrics in Express:
const client = require('prom-client');
const collectDefaultMetrics = client.collectDefaultMetrics;
collectDefaultMetrics({ timeout: 5000 });
app.get('/metrics', async (req, res) => {
res.set('Content-Type', client.register.contentType);
res.end(await client.register.metrics());
});
Key metrics: nodejs_eventloop_lag_seconds, nodejs_heap_size_used_bytes, http_request_duration_seconds, and process_cpu_user_seconds_total. Monitor them with Grafana or Prometheus alerts.
Database diagnostics: in MongoDB, check slow query log:
db.setProfilingLevel(1, { slowms: 100 });
Then examine:
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(5).pretty();
Failure Modes and Recovery
Capacity planning must include what happens when you exceed capacity. Common failure modes:
- Memory exhaustion – Node.js process crashes with
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory.
- Detection: process exits, PM2 or systemd restarts it (if configured).
- Diagnosis: check logs, heap dumps.
- Recovery: increase
--max-old-space-size, fix memory leak, or add more instances.
- Event loop lag – Requests queue up because the event loop is blocked (e.g., synchronous CPU-bound code).
- Detection: monitoring shows
nodejs_eventloop_lag_seconds> 0.1. - Diagnosis: CPU profiling, check for blocking operations.
- Recovery: move blocking work to worker threads, optimize code, or scale horizontally.
- Database connection pool exhaustion – API cannot get a connection from the pool, requests time out.
- Detection: logs show
MongoPoolClearedErrororMongoWaitQueueFullError. - Diagnosis: check
db.serverStatus().connectionsand wait queue size. - Recovery: increase
maxPoolSize(if database can handle it), reduce query latency, or add read replicas.
- Rate limit exceeded – If you have rate limiting on your API, excessive traffic may be rejected. This is often intentional, but capacity planning should include the limit value.
- Detection: HTTP 429 responses in logs.
- Diagnosis: check rate limiter counters.
- Recovery: adjust the limit based on capacity, or add more instances behind a load balancer.
For each failure mode, create a runbook with exact commands. Example for memory exhaustion:
Prerequisite: Node.js process managed by PM2. Read-only observation:
pm2 status
pm2 describe my-api
Check memory and restarts fields.
Recovery step: If crashes are frequent, restart with larger heap:
pm2 delete my-api
pm2 start server.js --node-args="--max-old-space-size=8192" --name my-api
Verification:
pm2 logs my-api --lines 20
Ensure no heap error appears, and monitor memory over time.
Rollback: If the issue persists, revert to default settings and investigate root cause.
Operations Checklist
Use this checklist before every capacity-related change or deployment:
- Identify component and version
node --version,npm list express mongodb- Record values.
- Capture current state
- CPU, memory, event loop lag, database connections.
- Save metrics to a file or monitoring dashboard.
- Define expected result
- What metric should improve? By how much? What is the acceptable range?
- Estimate blast radius
- Which users or features are affected? Can you test in staging?
- Prepare rollback plan
- Exact command or configuration to revert.
- Make the change
- One change at a time.
- Verify immediately
- Run load test or observe monitoring for at least 10 minutes.
- Compare before/after.
- Document
- Update runbook with outcome and any surprises.
- Monitor for 24-48 hours
- Watch for delayed effects (e.g., memory leak).
- Communicate
- Notify team of change and new capacity limits.
Example checklist run for increasing maxPoolSize from 5 to 20:
- Component: MongoDB Node.js driver v6.3.0.
- Current state:
db.serverStatus().connectionsshows current=15, available=50. - Expected result: under load test with 200 concurrent requests, API latency p99 decreases from 300ms to 150ms.
- Blast radius: all API requests using MongoDB; test in staging first.
- Rollback: set
maxPoolSizeback to 5. - Change: update connection string.
- Verify: run
autocannon -c 200 -d 60and compare. - Document: update capacity plan.
Conclusion
REST API capacity planning is not a one-time activity. It requires continuous observation, testing, and adjustment. By following the practices in this article—version inventory, safe configuration changes, verification with load tests, and recovery runbooks—you can prevent most capacity-related outages.
Start small: pick one endpoint, measure its current capacity, set a realistic target, and implement monitoring. Then gradually expand to the entire API surface. Remember: every change should be observed, verified, and reversible. With a solid capacity plan, your API can scale gracefully from 100 to 1,000,000 users.