Five practical Node.js performance fixes - compression, clustering, connection pooling, memory leak hunting, and N+1 queries - paired with the interview questions each one triggers.
Most Node.js apps aren't slow because the runtime is slow. They're slow because of five fixable things: no compression, one CPU core in use, no connection pooling, leaking memory, and N+1 queries. Here's how to fix each one, and the interview questions that come up once you do.
Most Node.js apps aren't slow because Node.js is slow. They're slow because of a handful of unglamorous gaps: no response compression, one CPU core doing all the work, a fresh database connection opened per request, a memory leak nobody's chased down, or a query loop that should've been a single join. Fix those five, and a server handling 100 requests a second can realistically handle 10x that.
For the runtime fundamentals behind these fixes - the event loop, libuv, and the thread pool - see the complete Node.js interview questions guide.
Node.js apps usually stall for structural reasons, not because the language is inherently slow: uncompressed responses, a single CPU core in use, and connections opened fresh per request all cap throughput long before the JavaScript itself becomes the bottleneck. The fixes below target those structural limits directly, in the order that gives you the most throughput per line of code changed.
The gap between "100 req/sec" and "10,000 req/sec" is almost never one big rewrite. It's five small, independent changes that compound - compression cuts payload size, clustering multiplies available compute, and pooling removes per-request connection overhead. None of them require touching your business logic.
Gzip compression shrinks response payloads before they hit the network, which cuts transfer time and lets your server serve more requests in the same window - a change small enough to add in one line of middleware. It costs a small amount of CPU per request to compress, but for JSON-heavy APIs that CPU cost is almost always cheaper than the bandwidth and latency it saves.
const express = require("express");
const compression = require("compression");
const app = express();
// Apply before your routes - compresses every response that qualifies
app.use(compression());
app.get("/api/products", async (req, res) => {
const products = await db.products.findAll();
res.json(products); // now gzipped automatically
});Interview questions on compression:
compression() has a threshold option (default 1KB) for exactly this reason.zlib's synchronous compression runs on the main thread by default for small payloads, though Node's zlib module can offload larger jobs. This is why compression is cheap per-request but not free at extreme request volume.Content-Encoding: gzip response header, or compare payload size with curl -H "Accept-Encoding: gzip" -o /dev/null -w "%{size_download}\n" <url> against an uncompressed request.Node.js runs your JavaScript on a single thread, so a server with 16 cores uses exactly one of them by default - the cluster module forks a worker process per CPU core, and the OS load-balances incoming connections across all of them automatically. This is the single highest-leverage fix on this list: it can multiply request-handling capacity by the number of cores on the box, with no application code changes.
const cluster = require("node:cluster");
const os = require("node:os");
const http = require("node:http");
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
for (let i = 0; i < numCPUs; i++) cluster.fork();
cluster.on("exit", (worker) => {
console.log(`Worker ${worker.process.pid} died - restarting`);
cluster.fork(); // keep the pool at full strength
});
} else {
http.createServer((req, res) => {
res.end(`Handled by worker ${process.pid}`);
}).listen(3000);
}Interview questions on clustering:
SharedArrayBuffer.pm2 start server.js -i max clusters across all cores and handles restarts, without you writing the fork logic yourself.Every new database connection means a fresh TCP handshake and an authentication round-trip - typically 10-100ms of pure overhead before your query even runs, and thousands of concurrent Node.js requests will exhaust the database's connection limit fast if each one opens its own. A connection pool keeps a fixed set of connections open and hands them out on demand, reusing what's already established.
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // cap concurrent connections - don't over-pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
async function getUser(id) {
// borrows a connection, runs the query, returns it to the pool
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [id]);
return rows[0];
}Interview questions on connection pooling:
max pool size?" It's bounded by the database's connection limit divided across every app instance, not by how much load you're handling - a pool of 20 per instance times 10 clustered workers is already 200 connections. Start conservative and watch for pool-exhaustion errors under load.pool.query() call queues until a connection frees up or connectionTimeoutMillis is hit, at which point it rejects. That's a deliberate backpressure valve, not a bug.PERSONAL EXPERIENCE - The most common Node.js memory leaks in production trace back to three patterns: event listeners registered per-request and never removed, unbounded in-memory caches (a plain object or Map with no eviction), and closures that accidentally hold a reference to a large object long after it's needed.
A Node.js memory leak happens when something still holds a reference to an object the application no longer needs, so the garbage collector can never reclaim it - and rss in process.memoryUsage() climbing steadily under steady load is the clearest early signal. Chrome DevTools, attached via node --inspect, lets you take heap snapshots and diff them to see exactly what's accumulating.
// Leak: listener piles up on every request, never removed
app.get("/dashboard", (req, res) => {
metricsEmitter.on("update", updateDashboard); // never cleaned up
res.render("dashboard");
});
// Fix: register once outside the handler, or use a bounded cache
const cache = new Map();
function setCached(key, value, maxSize = 1000) {
if (cache.size >= maxSize) {
cache.delete(cache.keys().next().value); // evict oldest
}
cache.set(key, value);
}Interview questions on memory leaks:
process.memoryUsage().rss on a metrics endpoint or dashboard and watch the trend over hours, not seconds - a leak shows up as a line that never comes back down after garbage collection, not as a spike.setInterval) that are never cleared when the thing they're tracking goes away.An N+1 query problem happens when code fetches a list, then loops over it firing one additional query per item - turning what should be one or two database round-trips into hundreds, and it's often invisible in local testing because a dev seed dataset is too small to expose it. Replacing the loop with a single join, or an IN (...) batch query, collapses N+1 calls into one.
// N+1: one query for orders, then one query PER order for its items
const orders = await db.query("SELECT * FROM orders WHERE user_id = $1", [userId]);
for (const order of orders) {
order.items = await db.query("SELECT * FROM items WHERE order_id = $1", [order.id]);
}
// Fixed: single join, grouped in application code
const rows = await db.query(
`SELECT o.*, i.* FROM orders o
JOIN items i ON i.order_id = o.id
WHERE o.user_id = $1`,
[userId]
);Interview questions on N+1 queries:
.map(), .forEach(), or for loop that iterates over a previous query's results - that shape is the tell, regardless of the ORM.DataLoader-style pattern (collect IDs, fire one IN (...) query, map results back) avoids a join when the relationship is more complex than SQL handles cleanly.include in Prisma, .populate() in Mongoose). The ORM doesn't know your access pattern; you have to tell it.Guessing the bottleneck wastes more time than measuring it - load-test the endpoint with a tool like autocannon before touching any code, then re-run the same benchmark after each change to confirm it actually moved the number. Fixing compression when the real bottleneck is a database connection ceiling won't show up as a win, and it'll look like the tip didn't work.
npx autocannon -c 100 -d 20 http://localhost:3000/api/products
# -c 100: 100 concurrent connections
# -d 20: run for 20 seconds
# Look at requests/sec and latency percentiles (p50, p99) in the outputInterview questions on benchmarking:
autocannon shows high latency but low CPU usage?" That pattern usually points away from compute and toward I/O wait - an unpooled database connection, a slow downstream API call, or event loop lag from a blocking operation. Check perf_hooks' monitorEventLoopDelay next.Beyond the five fixes above, interviewers regularly probe adjacent performance topics - caching layers, event loop health, and HTTP-level tuning - to see if your understanding generalizes past the specific tips you memorized.
Caching
lru-cache) for ultra-hot single-instance reads, and Redis for anything that needs to be shared across clustered workers or multiple app instances.Event loop health
perf_hooks' monitorEventLoopDelay() tracks how long the loop is delayed between iterations; alert when the mean delay crosses a threshold like 50-100ms, since that's roughly where users start perceiving slowness.crypto.pbkdf2Sync), and array methods run over very large in-memory datasets - all of these look like normal code but hold the thread for milliseconds to seconds.HTTP and networking
keepAlive: true set explicitly on the agent to benefit.Process and deployment
SIGTERM, let in-flight requests finish, then exit - and roll workers one at a time (pm2 reload) instead of restarting all of them simultaneously.Clustering usually wins - forking one worker per CPU core can multiply request-handling capacity by the number of cores with zero changes to business logic. Compression is a close second: it's a one-line middleware add that commonly cuts response transfer time enough to noticeably raise throughput on JSON-heavy APIs.
Compression, clustering, and connection pooling cover most apps that plateau under moderate load. Memory leaks and N+1 queries only need chasing once you're seeing symptoms - climbing RSS over hours, or query counts that don't match request counts - so profile before assuming you need every fix.
Compression, clustering basics, and N+1 queries show up at junior and mid-level - they're common enough that most working backend engineers hit them. Memory leak debugging and benchmark-driven optimization skew senior, since they assume you've owned a service in production long enough to have chased a real leak.
It's the fastest to reach for locally, but clinic.js (clinic doctor, clinic flame) goes further by profiling why a request is slow - event loop lag, I/O wait, or CPU-bound code - rather than just measuring req/sec. Know both: autocannon to measure, clinic to diagnose.
None of these five fixes touch business logic, and that's the point - compression, clustering, and connection pooling are configuration-level changes, while memory leaks and N+1 queries are pattern-recognition problems you fix once you know what to look for. Benchmark first, apply the fix that matches the bottleneck you actually measured, then benchmark again to confirm it worked.
For the deeper runtime questions these tips lead into - the event loop, worker threads, and production troubleshooting - see the Node.js interview questions guide and the advanced Node.js interview guide for staff-level scaling and security questions.