Skip to content
Knowledge Hub

Common performance mistakes and resource leaks in Node.js Intermediate

Estimated reading time: 8 min read

This page answers: why an API runs fine in testing but gets slow or OOMs in production. None of the mistakes below are logic errors; they only show up under load, with large data, or once the process has run long enough.

Measured on a dev machine with Node 24.17 and PostgreSQL 17. Absolute numbers will differ on your machine; the ratios are what to remember.

Loading diagram…

Group Symptom Mistakes on this page
Blocking the event loop p99 of every route rises at once sync crypto/fs, ReDoS
No limits Pool timeout errors, OOM on large data Promise.all, reading whole tables, fetch without timeout
No cleanup Heap grows steadily, a restart makes it go away Map caches, per-request listeners

A heavy synchronous function in a request handler

Common

app.post('/login', (req, res) => {
const hash = pbkdf2Sync(req.body.password, salt, 600_000, 64, 'sha512'); // ~100 ms
// ... readFileSync, JSON.parse of a large payload, zlib.gzipSync are the same
res.json({ ok: timingSafeEqual(hash, stored) });
});
// 4 concurrent /login requests -> GET /ping takes ~365 ms

Prefer

import { promisify } from 'node:util';
const pbkdf2Async = promisify(pbkdf2);
app.post('/login', async (req, res) => {
const hash = await pbkdf2Async(req.body.password, salt, 600_000, 64, 'sha512');
res.json({ ok: timingSafeEqual(hash, stored) });
});
// 4 concurrent /login requests -> GET /ping takes ~5 ms

Why

JavaScript runs on one main thread. pbkdf2Sync holds that thread for ~100 ms, so every other request (health checks included) has to queue. The async version pushes hashing to the libuv threadpool, leaving the main thread free to respond. Pure-JS CPU work (computation, large parsing) should move to worker_threads.

A regex with nested quantifiers (ReDoS)

Common

// Validate a display name: letters/digits, separated by spaces
const NAME_RE = /^([a-zA-Z0-9]+\s?)*$/;
NAME_RE.test('a'.repeat(30) + '!'); // ~4 seconds, main thread frozen
// each extra character: time roughly doubles

Prefer

const NAME_RE = /^[a-zA-Z0-9]+(?: [a-zA-Z0-9]+)*$/;
if (input.length > 100) throw new BadRequestException('Name too long');
NAME_RE.test('a'.repeat(100_000) + '!'); // ~0.2 ms

Why

(x+\s?)* allows splitting the string aaaa in a huge number of ways; when the match fails at !, the backtracking engine tries all of them. A 31-byte request is enough to hang the server for seconds. Avoid nested quantifiers ((a+)+, (a|a)*), and cap the length before running the regex.

Mapping a whole list into Promise.all

Common

const pool = new Pool({ max: 10, connectionTimeoutMillis: 2000 });
await Promise.all(ids.map((id) => syncOrder(id))); // 2,000 ids, each query ~20 ms
// 1111 / 2000 failed: "timeout exceeded when trying to connect"

Prefer

async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>) {
const results: R[] = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
await mapLimit(ids, 10, syncOrder); // 2000/2000 ok, at most 10 concurrent queries

Why

ids.map(...) starts everything immediately. 2,000 queries fight over 10 connections; those queued longer than connectionTimeoutMillis fail, and other users’ requests can’t get a connection either.

With external APIs you get rate limited or take the other side down. Limit concurrency with a function like the one above or the p-limit library.

An unbounded Map cache

Common

const cache = new Map<string, UserProfile>();
async function getProfile(id: string) {
if (!cache.has(id)) cache.set(id, await loadProfile(id));
return cache.get(id)!;
}
// 200,000 distinct users -> heap ~102 MB, and it never shrinks

Prefer

class LruCache<K, V> {
private map = new Map<K, V>();
constructor(private max: number) {}
get(k: K) {
const v = this.map.get(k);
if (v !== undefined) { this.map.delete(k); this.map.set(k, v); } // move to most recent
return v;
}
set(k: K, v: V) {
this.map.delete(k);
this.map.set(k, v);
if (this.map.size > this.max) this.map.delete(this.map.keys().next().value!); // evict oldest
}
}
const cache = new LruCache<string, UserProfile>(10_000); // heap ~9 MB
// Production: add a TTL, or use Redis if several instances need a shared cache.

Why

A module-level Map lives for the whole life of the process. Keyed by user/request, it grows until OOM, usually after a few days, exactly when nobody is deploying. An in-process cache needs a size limit and a TTL so data doesn’t stay stale forever.

Adding an event listener on every request

Common

app.get('/events', (req, res) => {
configBus.on('config-changed', (cfg) => res.write(`data: ${JSON.stringify(cfg)}\n\n`));
});
// 11th request: MaxListenersExceededWarning: Possible EventEmitter memory leak
// detected. 11 config-changed listeners added ... MaxListeners is 10.

Prefer

app.get('/events', (req, res) => {
const onChange = (cfg: Config) => res.write(`data: ${JSON.stringify(cfg)}\n\n`);
configBus.on('config-changed', onChange);
res.once('close', () => configBus.off('config-changed', onChange)); // listenerCount back to 0
});

Why

A listener holds a reference to its closure, which means it holds the req/res of a finished request. Without removal, memory grows with the request count, and every emit still runs closures of closed requests. The warning at 10 is a leak signal; don’t silence it with setMaxListeners(0).

fetch without a timeout

Common

const res = await fetch(`${RATES_API}/rates`);
// The other side accepts the connection but never responds: still pending after 3 seconds,
// the user's request hangs with it, connections and memory are held.

Prefer

try {
const res = await fetch(`${RATES_API}/rates`, { signal: AbortSignal.timeout(1000) });
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
// "The operation was aborted due to timeout" after ~1003 ms -> fallback / 503
}
throw err;
}

Why

By default fetch has no timeout matching your budget; the demo above is still pending after 3 seconds and keeps waiting. One slow dependency makes requests pile up, then exhausts the connection pool and memory. Set timeouts from the request’s budget; see Timeout budget.

Reading a whole table into RAM to export it

Common

const { rows } = await pool.query('select * from orders order by id'); // 1 million rows
res.json(rows);
// peak RSS ~372 MB, plus a synchronous JSON.stringify blocking the event loop

Prefer

let lastId = 0;
for (;;) {
const { rows } = await pool.query(
'select * from orders where id > $1 order by id limit 5000',
[lastId],
);
if (rows.length === 0) break;
for (const r of rows) res.write(toCsvLine(r)); // process batch by batch
lastId = rows[rows.length - 1].id;
}
res.end();
// same 1 million rows: peak RSS ~129 MB, doesn't grow with table size

Why

Loading everything makes RAM grow linearly with data: 1 million rows works today, 10 million next year is an OOM. Batching by keyset (id > lastId) keeps memory constant. When writing a lot, respect backpressure: wait for 'drain' when res.write returns false; see Streams and backpressure.

  • No *Sync calls (fs, crypto, zlib) or JSON.parse of large payloads in request handlers.
  • Regexes on user input have no nested quantifiers, and input length is capped.
  • Every Promise.all over a variable-length list has a concurrency limit.
  • In-process caches have a max size and a TTL.
  • Listeners registered per request are removed on close.
  • Every fetch/HTTP client has a timeout.
  • Export/batch jobs read in batches or streams, never select * on the whole table.

Next: go back to the Overview or read Node.js event loop.