Spot the bug in 10 seconds:

const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1000); const res = await fetch(url, { signal: controller.signal }); clearTimeout(timer); const body = await res.text(); 

The timeout is 1 second. The server sends its headers right away, then stalls the body for 3 seconds. I ran this on Node.js 22 and it waited 3.06 seconds. No error. No timeout. Nothing in the logs.

Found it? The answer is in Mistake 8. First, a picture of why bugs like this hurt so much.

Imagine a chai tapri with one counter and one person serving. A customer reaches the counter and spends five minutes counting coins. Everyone behind him waits too, even the person who only wants a 10-rupee chai. Nobody crashes. Nobody shouts. The line just gets longer.

That is your Node.js event loop. Your JavaScript runs on one thread, and one slow operation delays every request in the queue. Most mistakes below are variations of that picture: some block the counter, some wait forever with no timeout, some send the same order to the kitchen 100 times.

I reproduced ten mistakes like these on Node.js 22. They look fine in code review, they throw no errors, and they only hurt under real traffic. A few results from my tests:

  • A /fast route that took 190 ms instead of 0.7 ms because a different request was running (Mistake 1).
  • A cache that read the same file 100 times for 100 concurrent requests instead of once (Mistake 7).
  • An event-loop monitor that printed 20365311 ms of lag on a completely idle server (explained in the next section).

Every snippet marked Tested has a script you can run in the companion repo. Database and framework snippets are illustrative: names like pool, app and getUser stand in for your own code, and they were not run against a real database.

The golden rule: measure first

Optimizing without measuring is guessing. Start with a timer around suspicious code:

const { performance } = require('node:perf_hooks'); async function timedRoute(label, fn) { const start = performance.now(); const result = await fn(); console.log(`[PERF] ${label}: ${(performance.now() - start).toFixed(1)}ms`); return result; } 

Node ships more in perf_hooks: performance.now(), PerformanceObserver, performance.eventLoopUtilization() and monitorEventLoopDelay().

Timers only tell you how long something took. For the bigger picture, use a profiler and a load test:

  • node --cpu-prof server.js writes a .cpuprofile file when the process exits. Open it in Chrome DevTools.
  • Clinic.js draws flame graphs from a running app.
  • npx autocannon -c 100 -d 30 http://localhost:3000/your-route shows latency under 100 concurrent connections. Many bugs below only show up under concurrency.

The 20365311 gotcha

The docs say monitorEventLoopDelay() reports delays in nanoseconds. On an idle Node 22 process with resolution: 20, my p99 was 20,316,159. That is nanoseconds, about 20.3 ms. Print it with an ms label and you get twenty million milliseconds of lag, over five hours, on a server doing nothing. That was the number in the intro.

The 20 ms is not real lag. The values appear to include the sampling interval, so convert to milliseconds and subtract the resolution:

const { monitorEventLoopDelay } = require('node:perf_hooks'); const RESOLUTION_MS = 20; const h = monitorEventLoopDelay({ resolution: RESOLUTION_MS }); h.enable(); setInterval(() => { const toMs = (ns) => ns / 1e6; const p99 = Math.max(0, toMs(h.percentile(99)) - RESOLUTION_MS); const max = Math.max(0, toMs(h.max) - RESOLUTION_MS); console.log(`event loop lag: p99 ~ ${p99.toFixed(1)}ms, max ~ ${max.toFixed(1)}ms`); h.reset(); }, 10_000).unref(); 

Tested: with this 10-second window and one deliberate 300 ms synchronous block, p99 stayed around 0.6 ms while max reported about 297 ms. The window held about 476 samples, so a single stall is one sample and p99 can miss it. Watch max (or use a shorter window) as well, and pick alert thresholds from your own baseline.

Each mistake below has an Impact tag. These are my judgment, not a measurement: Outage-level can take the whole service down, High means large latency or load that grows with your data or traffic, and Medium is wasteful and noticeable at scale.

1. One slow function freezes every request

Impact: Outage-level.

Node runs your JavaScript on one thread. A synchronous operation inside a request handler delays every other request, not just the current one. Typical culprits are fs.readFileSync(), crypto.pbkdf2Sync(), huge JSON.parse() / JSON.stringify() calls, and heavy loops or regexes.

Tested: I ran a server in one process and a load generator in another. A pbkdf2Sync call took about 235 ms by itself. While it ran, requests to an unrelated /fast route had a median latency of 190 ms and a worst case of 233 ms. With the async pbkdf2, the same /fast route stayed at a median of 0.7 ms. Same work, same machine. The only difference is whether the counter was blocked.

Fixes

  1. Do synchronous work once at startup. readFileSync at boot is fine.
  2. At runtime, use the async APIs. crypto.pbkdf2, fs.promises, zlib and similar calls run on libuv's thread pool (4 threads by default, so it is small, not unlimited).
  3. For CPU-bound work you cannot make async, move it to worker_threads. Do not spawn a worker per request; use a pool such as Piscina.
const { Worker } = require('node:worker_threads'); function runInWorker(file, data) { return new Promise((resolve, reject) => { const w = new Worker(file, { workerData: data }); w.once('message', resolve); w.once('error', reject); w.once('exit', (code) => { if (code !== 0) reject(new Error(`worker exited with code ${code}`)); }); }); } 

A caveat: reading a small file synchronously from a local disk is cheap. Blocking becomes a real problem with large files, slow or network disks, and heavy CPU work, so measure before you rewrite.

Blocking is the famous one. The next one is the most common bug in database-backed APIs, and it hides behind code that reads well.

2. The N+1 query: one endpoint, one query per row

Impact: High.

You load a list, then run one more query per item. The code looks clean and the database suffers.

// N+1: 1 query for orders + 1 query per order async function getUserOrders(userId) { const { rows: orders } = await pool.query( 'SELECT * FROM orders WHERE user_id = $1', [userId] ); for (const order of orders) { const { rows } = await pool.query( 'SELECT * FROM order_items WHERE order_id = $1', [order.id] ); order.items = rows; } return orders; } 

Batch the child lookup so the query count stays at two, however many orders there are:

async function getUserOrders(userId) { const { rows: orders } = await pool.query( 'SELECT id, status, total FROM orders WHERE user_id = $1', [userId] ); if (orders.length === 0) return []; const { rows: items } = await pool.query( 'SELECT * FROM order_items WHERE order_id = ANY($1)', [orders.map((o) => o.id)] ); const byOrder = new Map(); for (const item of items) { if (!byOrder.has(item.order_id)) byOrder.set(item.order_id, []); byOrder.get(item.order_id).push(item); } return orders.map((o) => ({ ...o, items: byOrder.get(o.id) ?? [] })); } 

A single JOIN also works, but it repeats parent columns on every child row, so you have to regroup the rows. In GraphQL servers, DataLoader batches per-tick lookups for you.

With an ORM, N+1 hides behind property access: order.items looks like a field but fires a query. Use eager loading (include in Prisma and Sequelize) and read the SQL log once to confirm.

How to find it: log how many queries each request runs. A request that suddenly runs hundreds is the signal. Some APM tools flag repeated query patterns automatically, and this Scout write-up shows one way to detect it with Prisma.

3. Sending columns nobody reads

Impact: Medium.

SELECT *, or returning a whole ORM document, sends columns the client never uses. That costs database I/O, network bytes and serialization time, and it can leak fields you never meant to expose.

app.get('/api/users/:id', async (req, res) => { const { rows } = await pool.query( 'SELECT id, username, profile_image_url, created_at FROM users WHERE id = $1', [req.params.id] ); res.json(rows[0] ?? null); }); 

I did not measure the database side, but I can measure what the extra columns do to the response. Tested with synthetic rows: 10,000 rows with 4 fields serialize to 1.27 MB in about 3 ms. The same rows with 30 fields are 11.7 MB and take about 55 ms. That is roughly 9 times the bytes and 19 times the event-loop time, and the client only needed 4 fields.

That serialization time is the bridge to Mistake 10. First, a mistake that costs you latency without blocking anything.

4. Serial await that should run in parallel

Impact: Medium.

Independent calls should run together. Sequential:

// Total time is the sum of all three const user = await getUser(id); const orders = await getOrders(id); const prefs = await getPreferences(id); 

Parallel:

// Total time is about the slowest one const [user, orders, prefs] = await Promise.all([ getUser(id), getOrders(id), getPreferences(id), ]); 

Tested with three simulated 200 ms calls: sequential took about 602 ms, Promise.all about 201 ms.

Things to watch:

  • Only parallelize independent work. A checkout flow like validate cart, calculate totals, create order, charge payment is dependent: each step needs the previous result, so it must stay sequential.
  • await does not block the event loop. The cost of serial awaits is latency for that one request.
  • Promise.all rejects as soon as one promise rejects, and the others keep running (they are not cancelled). Use Promise.allSettled when you need every result.
  • Parallel queries each take a pool connection, so a high fan-out can exhaust a small pool (Mistake 6).

Three of these ten can take a whole service down, not just slow it. You have already seen one: Mistake 1. The other two are Mistake 6 and Mistake 8. But the next one is the one that gets quietly worse every month, until the day it does not.

5. The query that was fine at 100 rows

Impact: High.

A query that filters, joins or sorts on an unindexed column scans the whole table. That is fine at 100 rows and painful at millions. Confirm with EXPLAIN ANALYZE:

EXPLAIN ANALYZE SELECT id, status, total FROM orders WHERE customer_id = 42; -- Seq Scan on orders -> consider an index CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id); -- run EXPLAIN ANALYZE again: look for an Index Scan 

Indexes are not free. They use disk space and slow down writes, so add the ones your real queries need and verify they are used.

CONCURRENTLY avoids blocking writes while building an index on a live table, but it cannot run inside a transaction block (see the PostgreSQL docs). Many migration tools wrap each migration in a transaction, so mark that migration as non-transactional.

Also remember that EXPLAIN ANALYZE actually runs the query. For INSERT, UPDATE or DELETE, use plain EXPLAIN or run it inside a transaction you roll back.

6. A new database connection per request

Impact: Outage-level.

PostgreSQL (pg): create one pool at startup and reuse it. Never create a Client or Pool per request. And set the timeouts, because the defaults wait forever.

const { Pool } = require('pg'); const pool = new Pool({ max: 10, // per process. See the math below connectionTimeoutMillis: 5_000, // fail fast if no connection is free (default 0 = wait forever) statement_timeout: 10_000, // the server cancels any query running longer than 10 s }); // An idle client can error (for example when the database restarts). // Without a listener, that unhandled 'error' event can crash the process. pool.on('error', (err) => console.error('idle pg client error:', err.message)); app.get('/data', async (req, res) => { const { rows } = await pool.query('SELECT id, name FROM users LIMIT 50'); res.json(rows); }); 

For transactions or multi-statement work, check out a client and always release it. If ROLLBACK itself fails, the connection is in an unknown state. The node-postgres docs say that passing true to release() makes the pool destroy that client instead of reusing it:

async function transfer(a, b, amount) { const client = await pool.connect(); let destroy = false; try { await client.query('BEGIN'); // ... statements ... await client.query('COMMIT'); } catch (err) { try { await client.query('ROLLBACK'); } catch (rollbackErr) { destroy = true; // do not return a broken client to the pool console.error('ROLLBACK failed:', rollbackErr.message); } throw err; // always surface the original error } finally { client.release(destroy); } } 

The production math nobody does in dev

The pool limit is per process. Postgres has one global limit. If you run 10 instances with max: 20, that is 200 connections, and Postgres' default max_connections is typically 100 (it can be lower on some systems). Autoscaling multiplies the problem exactly when traffic is highest. Pick max so that instances x max stays under the database limit with headroom, or put a pooler like PgBouncer in front.

Connections are not free on the server either: each one is a separate Postgres process, and memory per connection depends on your workload (see this PGConf talk). Keep the count bounded and pooled. See the node-postgres pool docs for every option.

MongoDB: every MongoClient already contains a connection pool (default maxPoolSize is 100). The mistake is creating a new client per request. The driver docs say to create one client and reuse it for all operations in a process.

Two of the three outage-level mistakes are behind us. The third is the puzzle from the top. But first, a mistake that makes everything above cheaper.

7. Recomputing the same thing on every request

Impact: Medium.

If every request recomputes or refetches the same data, you pay for it on every request. Cache in layers:

  • In-process for small, hot, read-mostly data such as config and reference tables.
  • A shared cache (for example Redis) when several instances need the same data.
  • HTTP caching (Cache-Control, ETag) for browsers and CDNs.

But the obvious in-process cache has a trap. Here is a feature-flag loader that looks correct:

let cache = null; async function loadFlags() { if (cache) return cache; cache = JSON.parse(await fs.readFile('/etc/app/feature-flags.json', 'utf8')); return cache; } 

The value is stored only after the await. Every request that arrives while the first read is still running starts its own read. This is a cache stampede. Tested: 100 concurrent callers on a cold cache caused 100 file reads. Caching the promise instead caused 1.

Here is a small in-process cache that caches the promise, de-duplicates in-flight calls, evicts the least recently used entry, bounds its own size, and stops sharing a call that never finishes (pendingMs, which you should set above your function's own timeout from Mistake 8):

function cached(fn, ttlMs, { maxEntries = 1000, pendingMs = 30_000 } = {}) { const store = new Map(); // insertion order = least recently used first return (key) => { const now = Date.now(); const hit = store.get(key); if (hit && hit.expires > now) { // pending entries expire after pendingMs store.delete(key); store.set(key, hit); // touch: move to most recently used return hit.promise; } const entry = { promise: null, expires: now + pendingMs }; // new Promise(...) also turns a synchronous throw from fn into a rejection entry.promise = new Promise((resolve) => resolve(fn(key))).then((value) => { entry.expires = Date.now() + ttlMs; // the TTL starts when the call finishes return value; }); store.delete(key); store.set(key, entry); if (store.size > maxEntries) store.delete(store.keys().next().value); // evict LRU // never cache failures, and never delete a newer entry for the same key entry.promise.catch(() => { if (store.get(key) === entry) store.delete(key); }); return entry.promise; }; } const getUserCached = cached(loadUserFromDb, 5_000); 

Tested:

  • 100 concurrent calls for the same key triggered 1 underlying call.
  • After 10,000 distinct keys with maxEntries: 100, only the last 100 were still cached.
  • A hot key that is read again survives eviction; a cold key does not (LRU, not FIFO).
  • With a 600 ms call and a 400 ms TTL, a second call right after completion was a cache hit. If the TTL started when the call started, any call slower than its TTL would be expired the moment it finished.
  • A failed call is not cached, and a slow old failure does not delete a newer entry for the same key.
  • A call that never finishes is shared only for pendingMs. After that, the next caller starts a fresh call instead of waiting on a hung promise forever.

Caching has costs. Decide how stale data may be, how entries are invalidated, and how big the cache may grow. With several instances, in-process caches can disagree with each other.

Caching protects you from slow dependencies. The next mistake is about dependencies that never answer at all.

8. Outbound calls with no timeout, or a leaky one

Impact: Outage-level.

Here is the answer to the puzzle at the top. If you call clearTimeout as soon as fetch resolves, the timeout no longer covers reading the body:

// Timer is cleared when headers arrive, so a stalled body is NOT covered const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1000); const res = await fetch(url, { signal: controller.signal }); clearTimeout(timer); await res.text(); 

fetch() resolves when the headers arrive. The body is a separate stream, and it is still being read after your timer is gone.

Tested against a server that sends headers, then stalls the body for 3 seconds, with a 1-second timeout:

Pattern Result
Manual timer, cleared right after fetch finished after 3.06 s, no timeout applied
AbortSignal.timeout(1000) aborted after 1.00 s with a TimeoutError
Manual timer, cleared in finally after the body aborted after 1.00 s with an AbortError
<...