Review & Verify Updated 2026-09 10 min read View as Markdown

The performance traps in generated JavaScript

Node is single-threaded, so a performance bug is an availability bug. Every item here is really the same item: something blocked the event loop or ran unbounded.

JavaScript's performance story is different from every other language here because of one structural fact: in Node, blocking is not slow — it is downtime. One request that occupies the event loop for two seconds makes every concurrent user wait two seconds.

Generated code is written for clarity and is correct. What it omits is the awareness that the runtime is single-threaded.

Blocking the event loop#

1. Synchronous I/O in a request path#

js
app.get("/config", (req, res) => {
  const cfg = JSON.parse(fs.readFileSync("./config.json", "utf8"));   // blocks everyone
  res.json(cfg);
});

Every *Sync function in a handler blocks the whole process. Common offenders: readFileSync, existsSync, execSync, crypto.pbkdf2Sync, zlib.gunzipSync.

At startup, *Sync is fine and often clearer. In a request path it is never fine.

Catch it with: grep -rn 'Sync(' src/routes src/handlers — a targeted grep beats a lint rule here, because the same functions are legitimate at module load.

2. Large JSON.parse and JSON.stringify#

Both are synchronous and both are O(n). A 50 MB payload parses for hundreds of milliseconds with everything else stopped.

For large documents use a streaming parser; for large responses, stream the serialisation rather than building one string.

3. CPU-bound work in the main thread#

Image processing, hashing, compression, big sorts. Move it to a worker_threads pool, or out of the process entirely.

js
import { Worker } from "node:worker_threads";
// or, for a quick win, a pool: piscina

4. Catastrophic regex#

A generated regex with nested quantifiers against untrusted input hangs the event loop. It appears under performance and under security for the same reason — in Node the two are the same failure.

Async shapes#

5. Sequential awaits that should be parallel#

js
const user = await getUser(id);          // three round trips, one after another
const orders = await getOrders(id);
const prefs = await getPrefs(id);
js
const [user, orders, prefs] = await Promise.all([getUser(id), getOrders(id), getPrefs(id)]);

Correct either way; a third of the latency in the second. Look for: consecutive await lines with no data dependency between them.

6. await inside a loop#

js
for (const id of ids) results.push(await fetchUser(id));   // 100 sequential trips

The fix is Promise.all over .map — but not unbounded, which is the next item.

7. Unbounded concurrency#

js
await Promise.all(ids.map(fetchUser));    // 50,000 simultaneous requests

The overcorrection from item 6, and it is worse: file descriptor exhaustion, an immediate 429 storm, retries piling on top.

js
async function mapLimit(items, limit, fn) {
  const out = new Array(items.length);
  let i = 0;
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
  }));
  return out;
}

Eight lines and no dependency. Generated fan-out code essentially never includes a limit.

8. Promise.all where allSettled was meant#

Promise.all rejects on the first failure and abandons the rest — but the work already started keeps running, so you pay for it and discard it. For "fetch these ten things and show me what you got", allSettled.

Memory#

9. Listeners never removed#

js
emitter.on("data", handler);              // in a function called per request

Each call adds a listener that is never removed, so the emitter's array grows forever and each captured closure keeps its scope alive. Node's MaxListenersExceededWarning is the tell — treat it as a leak, not as noise.

The same applies to setInterval without clearInterval, and to AbortController listeners in long-lived objects.

10. Unbounded caches#

js
const cache = new Map();                  // grows until the process dies
cache.set(key, value);

Generated caching code is a bare Map or object with no eviction. Use an LRU with a size bound, or WeakMap/WeakRef when the key's lifetime should govern.

11. Closures holding large objects#

js
function makeHandler(hugeBuffer) {
  return () => doSomethingSmall(hugeBuffer.length);   // retains the whole buffer
}

The closure keeps everything in scope alive. Extract what you need before capturing.

Find leaks with: two heap snapshots in DevTools, compare, sort by retained size. The dominant retainer is usually one of these three.

Data access#

12. N+1 queries#

js
for (const order of orders) {
  order.items = await db.items.findMany({ where: { orderId: order.id } });
}

Correct, passes tests with three orders, 101 round trips in production. Fetch with IN/ANY and group in memory; with Prisma, include.

Catch it with: a test asserting query count. It is the only reliable defence and the highest-value performance test in a service.

13. Array operations chained over large data#

js
items.filter(f).map(m).filter(g).map(n)      // four full passes, three intermediate arrays

Fine at a thousand items, meaningful at a million. One reduce or a plain loop for hot paths — but do not do this pre-emptively; the readable version is right until profiling says otherwise.

14. Repeated array.includes in a loop#

O(n) inside O(n). new Set(array) once, outside the loop. Same class as the Python version and equally common.

Startup and delivery#

15. Everything imported at module load#

js
import { hugeLibrary } from "huge-library";   // parsed and executed at startup

For a serverless function this is cold-start latency on every invocation. Dynamic import() for anything used on a minority of paths.

16. Bundle size in the browser#

A single unnecessary dependency can double a bundle. npx source-map-explorer or your bundler's analyser tells you where the weight is, and the dependency table removes a surprising amount of it.

The reviewer's shortcut#

Three questions on any diff:

  1. Is anything synchronous and slow in a request path? (*Sync, big JSON.parse, a heavy regex)
  2. Is any await in a loop, and is any Promise.all unbounded?
  3. Does anything add a listener, an interval, or a cache entry without a matching removal?

Those catch items 1–3, 5–7, and 9–11 — most of the real cost here.

The Node-specific mental model

There is one CPU and everyone shares it. Any question of the form "is this slow?" is really "does this block, and for how long?" — and a 200 ms block under 100 concurrent requests is a 20-second tail. That framing catches more than any profiler.

Common questions#

How do I know if the event loop is blocked?#

clinic doctor tells you directly, and perf_hooks's monitorEventLoopDelay() gives you a histogram you can put on a dashboard. Event-loop lag at p99 is one of the two or three most useful metrics a Node service can export.

Are worker threads worth the complexity?#

For genuinely CPU-bound work that must stay in-process, yes — and use a pool rather than spawning per task, since worker startup is not free. For most services the better answer is moving that work out to a queue and a separate process entirely.

Is Promise.all bad?#

No — it is right for a handful of independent operations. It becomes a problem when the array is user-sized rather than code-sized. The rule: if the length comes from data rather than from the source code, bound the concurrency.

Should I optimise array chains pre-emptively?#

No. filter().map() is clearer and the difference is irrelevant below tens of thousands of elements. Profile first; the readable version is correct until measurement says otherwise.

Get the JavaScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.