October 1, 2025
12 Node.js Event-Loop Myths — Busted
A practical guide to what really happens in Node’s event loop — so you can ship faster, avoid stalls, and fix latency where it actually…

By Nexumo
4 min read
A practical guide to what really happens in Node's event loop — so you can ship faster, avoid stalls, and fix latency where it actually starts.
Stop believing these 12 Node.js event-loop myths. Clear explanations, tiny code demos, and production-minded tips to cut latency and improve throughput.
You've seen the memes: "Node is single-threaded, so just use async/await and everything's non-blocking." Cute. Also wrong in just enough ways to cause midnight incidents.
Let's be real: the event loop is simple and nuanced. Below are twelve myths I still hear in code reviews, with quick proofs and production-grade fixes. No fluff — just the stuff that moves your p95.
1) "Node is single-threaded, period."
The myth: One thread, one core, end of story.
Reality: JavaScript runs on one thread per process, but libuv uses a thread pool (default size: 4) for certain tasks — file I/O, DNS (getaddrinfo), crypto (scrypt/pbkdf2), zlib, etc. You can also use worker_threads and cluster.
// See the threadpool at work
const fs = require('fs');
for (let i = 0; i < 8; i++) {
fs.readFile(__filename, () => console.log('done', i));
}
// Tune size: UV_THREADPOOL_SIZE=64 node app.js (only helps specific workloads)// See the threadpool at work
const fs = require('fs');
for (let i = 0; i < 8; i++) {
fs.readFile(__filename, () => console.log('done', i));
}
// Tune size: UV_THREADPOOL_SIZE=64 node app.js (only helps specific workloads)Takeaway: Single-threaded JS ≠ single-threaded process. Use workers for CPU; the pool helps with some I/O.
2) "async/await makes code non-blocking."
The myth: Wrap it in await and you're safe.
Reality: await pauses your JS; whether the operation blocks the loop depends on what you await. fs.readFileSync, JSON.parse(huge), regex backtracking, and crypto sync calls will still block.
// Still blocks: synchronous hash
const crypto = require('crypto');
app.get('/hash', (req, res) => {
const h = crypto.createHash('sha256');
h.update(Buffer.alloc(100_000_000)); // big sync work
res.send(h.digest('hex')); // event loop stalls for everyone
});// Still blocks: synchronous hash
const crypto = require('crypto');
app.get('/hash', (req, res) => {
const h = crypto.createHash('sha256');
h.update(Buffer.alloc(100_000_000)); // big sync work
res.send(h.digest('hex')); // event loop stalls for everyone
});Fix: Offload CPU to worker_threads, use async APIs (fs.promises), and watch for "sync" variants in hot paths.
3) "setTimeout(fn, 0) runs immediately."
The myth: Zero equals now. Reality: Timers run in the timers phase, after the current tick and microtasks, and with system clock granularity. Other phases may fire first.
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// Order: nextTick -> promise -> timer (almost always)setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// Order: nextTick -> promise -> timer (almost always)Tip: For "right-after" semantics inside Node, use process.nextTick. Prefer Promises for portability across runtimes.
4) "Promises run before nextTick."
The myth: Microtasks are microtasks.
Reality: In Node, process.nextTick has higher priority than Promise microtasks. Overusing it can starve the loop.
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// -> nextTick, then promiseprocess.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// -> nextTick, then promiseGuideline: Use nextTick sparingly for API compatibility edges; default to Promises.
5) "setImmediate and setTimeout(0) are the same."
The myth: Both mean "as soon as possible."
Reality: setImmediate fires in the check phase; setTimeout in timers. After I/O, setImmediate often wins.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// -> typically: immediate, then timeoutconst fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// -> typically: immediate, then timeoutRule of thumb: After I/O, prefer setImmediate to schedule post-I/O work predictably.
6) "File system I/O is always non-blocking."
The myth: It's Node, so I/O can't block.
Reality: fs.*Sync APIs block. The async ones use the libuv pool, which can saturate under heavy load, effectively causing queues and latency spikes.
// Latency cliff: too many concurrent fs jobs for the default pool
for (let i = 0; i < 1000; i++) {
fs.readFile('bigfile.dat', () => {});
}// Latency cliff: too many concurrent fs jobs for the default pool
for (let i = 0; i < 1000; i++) {
fs.readFile('bigfile.dat', () => {});
}Fix: Increase UV_THREADPOOL_SIZE for I/O-heavy apps, batch reads, use streaming (createReadStream), and avoid sync calls in requests.
7) "Bigger threadpool always equals more throughput."
The myth: Crank pool to 128 and profit. Reality: Past a point you hit disk saturation, context-switching overhead, and worse tail latency.
Guideline: Tune with real traffic. Watch iostat, p95/p99, and queue times. Often 32–64 is enough for very I/O-bound services, and much lower for mixed workloads.
8) "Only while(true) locks the loop."
The myth: Infinite loops are the sole culprit. Reality: Large sync work (big JSON, compression, crypto, image ops, template rendering) can freeze the loop for hundreds of milliseconds.
app.post('/json', express.json({ limit: '100mb' }), (req, res) => {
// Massive sync parse already happened in middleware. Oops.
res.send({ ok: true });
});app.post('/json', express.json({ limit: '100mb' }), (req, res) => {
// Massive sync parse already happened in middleware. Oops.
res.send({ ok: true });
});Fix: Stream and chunk (e.g., busboy, stream.Readable), use zlib async methods, or move CPU to workers.
9) "Streams solve memory and backpressure automatically."
The myth: Pipe it and forget it.
Reality: Backpressure works only if you respect write()'s return value and wait for 'drain'.
const readable = getBigSource();
const writable = fs.createWriteStream('out.dat');
readable.on('data', chunk => {
if (!writable.write(chunk)) {
readable.pause();
writable.once('drain', () => readable.resume());
}
});const readable = getBigSource();
const writable = fs.createWriteStream('out.dat');
readable.on('data', chunk => {
if (!writable.write(chunk)) {
readable.pause();
writable.once('drain', () => readable.resume());
}
});Tip: Prefer stream.pipeline which wires backpressure and errors for you.
10) "Keep-Alive always reduces latency."
The myth: Persistent connections = faster, period. Reality: It usually helps, but with sticky sessions, L4 load balancers, or proxies with low idle timeouts, stale sockets can hang requests or cause uneven load.
Fix: Tune agentkeepalive or Node's http.Agent (maxSockets, maxFreeSockets, timeouts), align with proxy timeouts, and consider HTTP/2 for multiplexing.
11) "Cluster means true horizontal scaling."
The myth: cluster or PM2 = scale solved.
Reality: They give you multiple processes on one machine. You still need state strategy: sticky sessions, shared caches, idempotent work, centralized queues.
Practice: Store sessions in Redis (or better: stateless JWT), push jobs via a queue (BullMQ/RSMQ), and use a proper load balancer. Treat each worker as cattle.
12) "GC pauses are negligible in Node."
The myth: V8 handles it; move on. Reality: For memory-heavy services, GC can produce millisecond-scale hiccups. Long-lived arrays/Maps and accidental global caches are common culprits.
Fixes that work:
- Cap heap with
--max-old-space-size=only if you must; better to reduce live set. - Reuse buffers, avoid hidden-class churn, clear LRU caches on pressure.
- Measure with
--trace-gcorclinic flame | bubbleprof.
Tiny mental model that keeps you out of trouble
- Phases matter. (timers → pending → idle/prepare → poll → check → close)
- Microtasks run after each phase; in Node,
nextTickoutranks Promise microtasks. - I/O async ≠ CPU async. Use workers for CPU.
- Backpressure is explicit. Respect it or leak memory.
- Tune with data. Threadpool, keep-alive, and GC are contextual.
A minimal "safe by default" server
import http from 'node:http';
import { pipeline } from 'node:stream';
import { createReadStream } from 'node:fs';
const server = http.createServer((req, res) => {
// Early reject (cheap) to avoid work on bad requests
if (req.method !== 'GET') { res.writeHead(405).end(); return; }
// Avoid sync work; use streaming + backpressure
res.setHeader('Connection', 'keep-alive');
pipeline(createReadStream('./public/big.dat'), res, (err) => {
if (err) { res.destroy(err); }
});
});
// Reasonable keep-alive and socket caps
server.keepAliveTimeout = 5000;
server.headersTimeout = 7000;
server.maxConnections = 2048;
server.listen(3000);import http from 'node:http';
import { pipeline } from 'node:stream';
import { createReadStream } from 'node:fs';
const server = http.createServer((req, res) => {
// Early reject (cheap) to avoid work on bad requests
if (req.method !== 'GET') { res.writeHead(405).end(); return; }
// Avoid sync work; use streaming + backpressure
res.setHeader('Connection', 'keep-alive');
pipeline(createReadStream('./public/big.dat'), res, (err) => {
if (err) { res.destroy(err); }
});
});
// Reasonable keep-alive and socket caps
server.keepAliveTimeout = 5000;
server.headersTimeout = 7000;
server.maxConnections = 2048;
server.listen(3000);Why it's fast: No sync I/O, built-in backpressure via pipeline, early rejections, and sane connection timeouts.
Production checklist (pin this)
- Eliminate sync calls on request paths (
fs.*Sync, heavyJSON.parse, sync crypto). - Measure the pool before increasing
UV_THREADPOOL_SIZE. - Use workers for CPU-bound tasks; keep messages small.
- Respect backpressure (
stream.pipeline), monitor high-water marks. - Tune Agents/keep-alive to match your proxies and ALB timeouts.
- Watch GC with logs and heap snapshots; kill accidental caches.
You might be wondering, "Which myth bites teams most?" In my experience: #2 and #9. async/await hides sync landmines, and streams look magical until memory balloons. Fix those first; your event loop will thank you.
CTA: Which myth have you wrestled with in production? Drop a comment (and a snippet). I'll reply with a quick, testable fix you can ship today.