Node.js Performance Optimization: 20 Practical Tips

Node.js is fast by default, but it's easy to write code that quietly throws that speed away — a blocking loop here, an unbounded cache there, a missing index somewhere else. This post walks through 20 practical, battle-tested tips for making a Node.js application faster and more predictable under load, grouped into four themes: measuring performance, async/I/O patterns, memory and caching, and scaling in production.
Each tip includes a short explanation of why it matters and a code example you can adapt directly.
Part 1: Understanding and measuring performance
1. Profile before you optimize
The single biggest mistake in performance work is optimizing based on intuition instead of data. Node gives you real profiling tools out of the box — use them before touching any code.
# Generate a CPU profile
node --prof app.js
# Process the profile into a readable report
node --prof-process isolate-0x*-v8.log > profile.txtFor a friendlier flame graph, Clinic.js is worth installing:
npm install -g clinic
clinic doctor -- node app.jsclinic doctor will point you at whether you have an event loop, I/O, or memory problem — which tells you which of the tips below to reach for first.
2. Understand the event loop's phases
Node's event loop runs in distinct phases — timers, pending callbacks, poll, check, and close callbacks. Code that "feels async" can still starve other phases if you don't know where it runs.
console.log('start');
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
console.log('end');
// Output order: start, end, nextTick, timeout, immediate (order of timeout/immediate
// can vary depending on context, but nextTick always runs before both)process.nextTick and microtasks (resolved promises) run before the next event loop phase — which means stacking too many of them can still starve I/O, even though it's "async" code.
3. Spot and eliminate blocking code
Anything synchronous and CPU-heavy on the main thread blocks every other request. This is the most common Node performance bug in production.
// Bad: blocks the event loop for every request
app.get('/hash', (req, res) => {
const hash = crypto.pbkdf2Sync(req.query.password, 'salt', 100000, 64, 'sha512');
res.send(hash.toString('hex'));
});
// Better: use the async variant
app.get('/hash', (req, res) => {
crypto.pbkdf2(req.query.password, 'salt', 100000, 64, 'sha512', (err, derivedKey) => {
if (err) return res.status(500).end();
res.send(derivedKey.toString('hex'));
});
});Common offenders: JSON.parse on huge payloads, fs.readFileSync, synchronous regex on large strings, and tight loops over big arrays.
4. Use async/await without creating hidden bottlenecks
async/await reads sequentially, which tempts you into writing code that's accidentally serial when it doesn't need to be.
// Bad: these two calls run one after another, wasting ~200ms
const user = await getUser(id);
const posts = await getPosts(id);
// Better: run independent calls in parallel
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);Only await sequentially when the second call genuinely depends on the result of the first.
5. Set up APM and tracing
Once you're past local profiling, you need visibility in production. OpenTelemetry is the vendor-neutral standard, and it plugs into Datadog, New Relic, Honeycomb, or Grafana Tempo.
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const sdk = new NodeSDK({
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();This gives you per-request traces across HTTP calls, DB queries, and external APIs — so you can see exactly where latency is coming from instead of guessing.
Part 2: I/O and async patterns
6. Stream large payloads instead of buffering
Loading an entire file or response into memory before sending it wastes memory and delays the first byte to the client.
// Bad: loads the whole file into memory
app.get('/download', (req, res) => {
const data = fs.readFileSync('large-file.csv');
res.send(data);
});
// Better: stream it
app.get('/download', (req, res) => {
const stream = fs.createReadStream('large-file.csv');
stream.pipe(res);
});The same principle applies to proxying responses from other services — pipe them through rather than buffering and re-sending.
7. Batch and debounce database calls
Firing one query per item in a loop is a classic source of latency. Batch multiple lookups into a single round trip wherever possible.
// Bad: N separate queries
for (const id of userIds) {
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
}
// Better: one query for all of them
const users = await db.query('SELECT * FROM users WHERE id = ANY($1)', [userIds]);For write-heavy workloads (analytics events, logs), consider a small in-memory queue that flushes in batches every N milliseconds instead of writing on every event.
8. Use connection pooling correctly
Opening a new database connection per request is expensive. Pools reuse connections, but only if sized correctly for your workload.
const { Pool } = require('pg');
const pool = new Pool({
max: 20, // max connections in the pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Reuse the pool across the app instead of creating new clients per request
const result = await pool.query('SELECT * FROM orders WHERE id = $1', [orderId]);A pool that's too small causes requests to queue and time out; one that's too large can overwhelm the database. Size it against your DB's actual connection limit, not just a guess.
9. Parallelize independent async operations
Beyond Promise.all, know your other combinators for different failure semantics:
// Fails fast if any promise rejects
const results = await Promise.all([fetchA(), fetchB(), fetchC()]);
// Waits for all, returns status of each — good when partial failures are OK
const settled = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
// Resolves as soon as the first one succeeds
const first = await Promise.any([mirror1(), mirror2(), mirror3()]);Promise.allSettled is particularly useful for dashboards or aggregation endpoints where one failing data source shouldn't take down the whole response.
10. Kill the N+1 query problem
This deserves its own callout because it's the most common ORM-driven performance killer.
// Bad: 1 query for posts + N queries for each post's author
const posts = await Post.findAll();
for (const post of posts) {
post.author = await User.findByPk(post.authorId);
}
// Better: eager-load the relationship in one query
const posts = await Post.findAll({ include: [{ model: User, as: 'author' }] });Most ORMs (Sequelize, TypeORM, Prisma) support eager loading or include/with — use it whenever you're about to loop over a result set and fetch a related record for each row.
Part 3: Memory, caching and CPU-bound work
11. Find and fix memory leaks
Leaks in Node usually come from references that never get released: growing arrays, uncleared timers, or event listeners that outlive their subscribers.
// Bad: listener is added on every request and never removed
app.get('/subscribe', (req, res) => {
eventEmitter.on('update', (data) => res.write(data));
});
// Better: remove the listener when the connection closes
app.get('/subscribe', (req, res) => {
const handler = (data) => res.write(data);
eventEmitter.on('update', handler);
req.on('close', () => eventEmitter.off('update', handler));
});Use node --inspect with Chrome DevTools' heap snapshot comparison to confirm a leak before chasing it — take two snapshots under load and diff the retained objects.
12. Cache strategically with Redis or in-memory stores
Not everything needs a database round trip. Cache read-heavy, rarely-changing data with a sensible expiry.
const redis = require('redis').createClient();
async function getProduct(id) {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await db.query('SELECT * FROM products WHERE id = $1', [id]);
await redis.set(`product:${id}`, JSON.stringify(product), { EX: 300 }); // 5 min TTL
return product;
}Always set a TTL — an unbounded cache is just a memory leak with extra steps.
13. Optimize JSON serialization for large payloads
JSON.stringify on very large objects is synchronous and can block the event loop for tens of milliseconds. For high-throughput APIs, faster serializers help.
const fastJson = require('fast-json-stringify');
const stringify = fastJson({
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
price: { type: 'number' },
},
});
res.end(stringify(product));Schema-based serializers like fast-json-stringify can be several times faster than the built-in JSON.stringify because they skip runtime type-checking.
14. Offload CPU-heavy work to worker threads
CPU-bound tasks (image resizing, heavy computation, encryption) block the single main thread. Worker threads let you use multiple cores without leaving Node.
const { Worker } = require('worker_threads');
function runInWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./cpu-task.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
});
}// cpu-task.js
const { parentPort, workerData } = require('worker_threads');
const result = heavyComputation(workerData);
parentPort.postMessage(result);Reach for worker threads specifically for CPU-bound work — I/O-bound work is already handled well by Node's async model and doesn't benefit from threads.
15. Tune garbage collection and heap limits
Node's default heap size may not fit your workload. Under memory pressure, frequent GC pauses add latency spikes.
# Increase the old-space heap limit (in MB) for memory-heavy workloads
node --max-old-space-size=4096 app.js
# Log GC activity to see if it's a contributing factor
node --trace-gc app.jsIf --trace-gc shows frequent, long "Mark-sweep" pauses, that's a signal you're either leaking memory or need a larger heap — not just a GC tuning problem.
Part 4: Scaling and production readiness
16. Use cluster mode or PM2 to use all CPU cores
Node runs on a single thread by default, meaning a single instance only uses one CPU core. The cluster module or a process manager like PM2 forks multiple workers to use all available cores.
const cluster = require('cluster');
const os = require('os');
if (cluster.isPrimary) {
const cpuCount = os.cpus().length;
for (let i = 0; i < cpuCount; i++) cluster.fork();
} else {
require('./app.js'); // each worker runs the actual server
}Or, more simply in production, with PM2:
pm2 start app.js -i max17. Put a reverse proxy and load balancer in front
Nginx (or a cloud load balancer) in front of your Node instances handles TLS termination, static file serving, gzip compression, and request buffering — all things Node does less efficiently itself.
upstream node_app {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
listen 80;
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}This also gives you a place to add caching headers and rate limiting outside the application layer.
18. Add rate limiting and handle backpressure
Without limits, a traffic spike or a slow downstream client can overwhelm your server or exhaust memory buffering unsent data.
const rateLimit = require('express-rate-limit');
app.use(rateLimit({
windowMs: 60 * 1000,
max: 100, // limit each IP to 100 requests per minute
}));For streaming responses, respect the return value of stream.write() — when it returns false, pause writing until the 'drain' event fires, instead of buffering everything in memory.
19. Order middleware for minimum overhead
Express and Fastify run middleware in the order it's registered. Expensive middleware (body parsing, auth checks) running on every request — including ones that don't need it — adds unnecessary latency.
// Bad: body parser runs on every route, even ones that don't need a body
app.use(bodyParser.json());
app.get('/health', (req, res) => res.send('ok'));
// Better: scope expensive middleware to the routes that need it
app.get('/health', (req, res) => res.send('ok'));
app.post('/orders', bodyParser.json(), createOrder);Put cheap, frequently-short-circuiting checks (auth token presence, health checks) before expensive ones (body parsing, validation).
20. Set up continuous performance monitoring and alerting
Performance work isn't a one-time pass — regressions creep back in as code changes. Track key metrics continuously and alert on regressions:
const promClient = require('prom-client');
const httpDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
});
app.use((req, res, next) => {
const end = httpDuration.startTimer();
res.on('finish', () => {
end({ method: req.method, route: req.route?.path, status: res.statusCode });
});
next();
});Export these metrics to Prometheus/Grafana and set alerts on p95/p99 latency and error rate — catching a regression in staging is far cheaper than catching it from a user complaint.
Wrapping up
Most Node.js performance problems fall into one of four buckets: blocking the event loop, inefficient I/O patterns, unbounded memory growth, or under-provisioned scaling. Profiling first (Tip 1) tells you which bucket you're actually in — the rest of these tips are the fixes for each one. Start there, and let the data — not guesswork — decide what to optimize next.