Answer: The event loop is what lets a single-threaded runtime handle many operations concurrently. It runs in repeated iterations ("ticks"), each moving through ordered phases, running the callbacks queued for that phase.
Phases (in order):
- Timers — callbacks scheduled by
setTimeout/setIntervalwhose time has elapsed. - Pending callbacks — certain deferred system callbacks (e.g., some TCP errors).
- Idle/prepare — internal use.
- Poll — retrieve new I/O events and run their callbacks (reading files, sockets, etc.). If nothing is pending, it may block here waiting for I/O.
- Check —
setImmediatecallbacks run here. - Close callbacks — e.g.,
socket.on('close', ...).
Between phases: microtasks. After each phase (and after each macrotask callback), Node drains:
- the
process.nextTickqueue, then - the Promise microtask queue. Microtasks always run before the loop proceeds to the next phase.
console.log('start');
setTimeout(() => console.log('timeout'), 0); // timers phase
setImmediate(() => console.log('immediate')); // check phase
Promise.resolve().then(() => console.log('promise')); // microtask
process.nextTick(() => console.log('nextTick')); // runs before promises
console.log('end');
// start, end, nextTick, promise, timeout, immediate
Key mental model: run synchronous code → drain microtasks → run one phase's callbacks → drain microtasks → next phase. Long synchronous work or a flood of nextTick callbacks can starve I/O.
Answer:
Macrotasks are handled by event-loop phases:
setTimeout/setInterval→ timers phase- I/O completion → poll phase
setImmediate→ check phase
Microtasks run between macrotasks and are drained completely before moving on:
process.nextTickcallbacks (Node-specific, highest priority)- Promise reactions (
.then/catch/finally,awaitcontinuations)
Priority within a tick: current operation → all process.nextTick → all Promise microtasks → next macrotask.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// nextTick, promise, timeout, immediate (order of first two is guaranteed)
setTimeout(fn, 0) vs setImmediate(fn):
- At the top level, their order is not guaranteed (depends on how fast the loop reaches the timers phase).
- Inside an I/O callback,
setImmediatealways fires before asetTimeout(0), because the check phase comes right after poll:
fs.readFile('f.txt', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// immediate, then timeout — deterministic here
});
Caution with process.nextTick: because it's drained before anything else, recursively scheduling nextTick can starve the event loop (I/O never gets a turn). Prefer setImmediate when you just want to yield.
Answer: Because your JavaScript runs on one thread, any long-running synchronous operation prevents the event loop from handling other events. Requests queue up and latency spikes for all clients.
Common culprits:
- Tight CPU loops (image processing, encryption, big data transforms).
- Synchronous FS calls (
fs.readFileSync) on hot paths. JSON.parse/JSON.stringifyon very large payloads.- Complex synchronous regexes (ReDoS).
crypto.pbkdf2Sync/bcryptsync variants.
// ❌ Blocks the loop — no other request is served during this
app.get('/hash', (req, res) => {
const result = expensiveSyncHash(req.query.data);
res.send(result);
});
Ways to avoid blocking:
- Use async APIs —
fs.promises.readFile, async crypto (crypto.pbkdf2), etc. - Offload CPU work to
worker_threads(or a job queue / separate microservice):
const { Worker } = require('worker_threads');
app.get('/hash', (req, res) => {
const worker = new Worker('./hash-worker.js', { workerData: req.query.data });
worker.once('message', result => res.send(result));
});
- Chunk long loops — break work across ticks with
setImmediateso I/O gets turns. - Stream large data instead of buffering it all in memory.
Diagnosing: watch event-loop lag (e.g., perf_hooks.monitorEventLoopDelay), and profile with --prof or clinic.js.
Answer: All three add parallelism, but for different needs.
worker_threads — real threads within a single process:
- Each has its own V8 isolate and event loop, but they can share memory via
SharedArrayBufferand pass messages. - Best for CPU-bound JavaScript (parsing, compression, ML inference) without spinning up whole processes.
const { Worker } = require('worker_threads');
const w = new Worker('./cpu-task.js', { workerData: input });
w.on('message', result => { /* ... */ });
cluster — forks multiple Node processes (workers) that share a listening socket:
- Lets an I/O-bound server use all CPU cores (one process per core), since a single process only uses one core for JS.
- Processes are isolated (no shared memory); the OS/round-robin load-balances connections.
- In production this is often handled by a process manager (PM2) or the platform (containers behind a load balancer).
const cluster = require('cluster');
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) cluster.fork();
} else {
http.createServer(handler).listen(3000);
}
child_process — spawn any external program or another Node script:
spawn(streams, long-running),exec(buffered, shell commands),fork(a Node child with an IPC channel).
const { spawn } = require('child_process');
spawn('ffmpeg', ['-i', 'in.mp4', 'out.webm']);
Choosing:
| Need | Use |
|---|---|
| Parallelize CPU-heavy JS, share memory | worker_threads |
| Use all cores for an I/O server | cluster (or PM2) |
| Run an external binary / separate script | child_process |
Answer: Not every asynchronous operation in Node is handled the same way. libuv splits work into two categories.
1. OS-level async (no threads needed): Network sockets use the operating system's event notification (epoll on Linux, kqueue on macOS, IOCP on Windows). These scale to many thousands of connections without extra threads.
2. The thread pool (for work with no async OS API): Some operations can only be done synchronously at the OS level, so libuv runs them on a pool of background threads and calls back when done:
- File system operations (
fs.readFile, etc.) - DNS resolution via
dns.lookup/getaddrinfo - Some
cryptofunctions (pbkdf2,randomBytesasync forms) zlibcompression
Pool size:
- Default is 4 threads. Configurable via the
UV_THREADPOOL_SIZEenvironment variable (up to 1024), set before the process starts.
UV_THREADPOOL_SIZE=8 node app.js
Why it matters in interviews:
- If you fire many concurrent
fs/crypto/dns.lookupcalls, they contend for those 4 threads — extra ones queue. This can cause surprising latency under load. dns.lookupuses the pool, butdns.resolve(which queries DNS servers over the network) does not — a classic gotcha.- Raising
UV_THREADPOOL_SIZEcan help throughput for pool-bound workloads, but it doesn't help pure network I/O.