Common performance mistakes and resource leaks in Node.js Intermediate
Estimated reading time: 8 min read
Read first
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.
Three ways to break
Section titled “Three ways to break”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 |
Blocking the event loop
Section titled “Blocking the event loop”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 msPrefer
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 msWhy
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 spacesconst NAME_RE = /^([a-zA-Z0-9]+\s?)*$/;NAME_RE.test('a'.repeat(30) + '!'); // ~4 seconds, main thread frozen// each extra character: time roughly doublesPrefer
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 msWhy
(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.
Unbounded Promise.all
Section titled “Unbounded Promise.all”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 queriesWhy
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.
Memory leaks
Section titled “Memory leaks”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 shrinksPrefer
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).
Outbound calls
Section titled “Outbound calls”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.
Large data
Section titled “Large data”Reading a whole table into RAM to export it
Common
const { rows } = await pool.query('select * from orders order by id'); // 1 million rowsres.json(rows);// peak RSS ~372 MB, plus a synchronous JSON.stringify blocking the event loopPrefer
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 sizeWhy
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.
Quick checklist
Section titled “Quick checklist”- No
*Synccalls (fs, crypto, zlib) orJSON.parseof large payloads in request handlers. - Regexes on user input have no nested quantifiers, and input length is capped.
- Every
Promise.allover 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.