Full-Stack Questions
Node.js Internals Interviewers Ask About
Event loop phases, microtask and nextTick order, the threadpool, backpressure, workers, memory, and the Node 22 tools with stable or experimental labels.
On this page
- Words used in this lesson
- Part 1. What runs where
- Part 2. The event loop, phase by phase
- Part 3. The threadpool and what uses it
- Part 4. Streams and backpressure
- Part 5. workerthreads, cluster and childprocess
- Part 6. AsyncLocalStorage
- Part 7. Memory and garbage collection
- Part 8. Built-in tools in Node 22
- Part 9. Graceful shutdown
- Stable or experimental in Node v22.22.0
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: Node.js runs your JavaScript on one thread, but it is built from three parts that work together (V8 runs the code, libuv runs the event loop and a small pool of helper threads, and the OS does the network I/O), and most "internals" interview questions are really about which part does the work, and in what order callbacks run.
Interviewers at the 4+ years level do not ask "what is Node?". They ask why a setTimeout runs after a promise, why a hash blocks other file reads, what happens when a write is faster than the disk, and how you stop a server without dropping requests. A good answer names the part (V8, libuv, threadpool, OS) and shows a small experiment.
This lesson skips the basics. For modules, Express, middleware and the first look at the event loop, see Full-Stack Q&A, Lesson 06. For promises, async/await, the browser loop and ordering puzzles, see Lesson 16. For a short look at blocking and workers, see Backend, Lesson 10. For the test tools Jest and Vitest, see Lesson 21. Here we go one level deeper and add the newer built-in tools of Node 22.
What I ran. Everything ran on the installed Node.js v22.22.0 (Linux, 4 CPU cores, 16 GB RAM). I read the docs from the nodejs/node repository at the tag v22.22.0 (doc/api/*.md) and the libuv design docs from the libuv/libuv repository (docs/src/design.rst and threadpool.rst, branch v1.x). Stability labels below are copied from those v22.22.0 docs, not from memory. What I could not read: the "Event Loop, Timers, and process.nextTick()" guide on nodejs.org (the site is blocked in my sandbox and I could not find the source file in the website repo), so my event-loop description comes from the libuv docs, the Node API docs and my own runs. Timings are from my machine only; I draw conclusions from their shape, and I say so. Where I say "I ran", I did.
Words used in this lesson
- V8 is the JavaScript engine (made for Chrome) that Node uses to run your code and manage memory. libuv is the C library that gives Node its event loop, timers, file and network I/O, and a pool of helper threads.
- The event loop is a loop that waits for events (a timer is due, a socket has data) and calls the matching JavaScript function (a callback). It runs on the main thread.
- A phase is one step of the loop, with its own queue of callbacks.
- The call stack is where the JavaScript that is running right now lives. A microtask is a small job (a promise reaction, or
queueMicrotask) that runs as soon as the stack is empty. The nextTick queue is a separate Node-only queue that runs even before microtasks. - The threadpool is a small, fixed group of helper threads inside libuv. Some work that cannot be done without blocking (file reads, DNS lookups, some crypto and compression) is sent there.
- Blocking means the main thread is busy and cannot run other callbacks. Event loop lag is how late a timer fires because of it.
- A stream is a way to process data piece by piece (a chunk) instead of all at once. Backpressure is the signal "slow down, I cannot take more yet" from the consumer back to the producer.
highWaterMarkis the size at which a stream says it is full. - A worker thread is another JavaScript thread in the same process, with its own V8 and event loop. A child process is a separate OS process. A cluster is a set of child processes that share one server port.
AsyncLocalStoragekeeps a value that follows an async call chain, like a per-request variable.- A garbage collector (GC) frees memory of objects that nothing can reach any more. The heap is the memory area for JavaScript objects. A heap snapshot is a file that records every object in the heap.
- A signal is a short message from the OS to a process, like
SIGTERM("please stop") orSIGKILL("stop now, no choice"). Graceful shutdown means stopping in an orderly way: finish current work, close resources, then exit. - Stability index is the label in the Node docs for how safe an API is to rely on: Stable (2) is safe, Experimental (1) may change, Active development (1.1) is still being built, Legacy (3) is kept but not recommended.
Part 1. What runs where
your JavaScript ---> V8 (runs it, one thread) | vNode core (fs, net, http, crypto ...) ---> libuv |-- event loop (main thread): timers, sockets, callbacks |-- threadpool (4 threads by default): fs, dns.lookup, zlib, some crypto `-- OS facilities: epoll on Linux, kqueue on macOS, IOCP on WindowsThe libuv design document says it clearly: network I/O is always done on the loop's own thread with non-blocking sockets and the best OS mechanism (epoll on Linux), and a thread pool is used for file system operations and DNS (getaddrinfo, getnameinfo). So "Node is single threaded" is true for your JavaScript, and false for the whole process. A Node process always has more threads: I counted the entries in /proc/self/task inside a tiny script and got 7 (the main thread, V8 helper threads and the libuv workers).
Part 2. The event loop, phase by phase
The libuv docs list one iteration of the loop. Mapped to Node, the main phases are:
| Phase (libuv) | What runs in it in Node | Example |
|---|---|---|
| Timers | Callbacks of setTimeout and setInterval that are due | setTimeout(f, 0) |
| Pending callbacks | Some deferred system callbacks from the last iteration | rare, you seldom see it |
| Idle, prepare | Internal use only | |
| Poll | Waits for I/O and runs I/O callbacks (the loop may block here if nothing else is due) | a socket has data, a file read finished |
| Check | Callbacks of setImmediate | setImmediate(f) |
| Close callbacks | Close events of sockets and handles | socket.on('close') |
Two details from the libuv document that people forget:
- Before blocking in the poll phase, libuv computes how long it may wait. The libuv rules: the timeout is 0 if the loop was told not to wait, if it is being stopped, if there is nothing active, if idle handles are active, or if handles are closing. In every other case it waits until the nearest timer is due, or forever if there is no timer. (That
setImmediatecallbacks make the poll phase skip the wait is my reading of the behaviour: in my runs immediates always ran right after the I/O callbacks, before a new timer check.) - A timer is never "exactly on time". A timer says "run after at least N ms". If the loop is busy, it runs later.
Where microtasks and process.nextTick fit
They are not phases. After the JavaScript stack empties, Node runs the nextTick queue until empty, then the microtask queue (promises, queueMicrotask) until empty. This happens after each callback, not once per phase. I ran this:
setTimeout(() => { console.log('timer 1'); Promise.resolve().then(() => console.log(' microtask from timer 1')); process.nextTick(() => console.log(' tick from timer 1')); }, 0);setTimeout(() => console.log('timer 2'), 0);It printed timer 1, tick from timer 1, microtask from timer 1, and only then timer 2. So between two timers the queues are emptied (the nextTick queue first). The docs of process.nextTick say the queue "is fully drained after the current operation on the JavaScript stack runs to completion and before the event loop is allowed to continue".
The ordering experiment
In a CommonJS file, run as node order1.js:
setTimeout(() => console.log('timeout 0'), 0);setImmediate(() => console.log('immediate'));process.nextTick(() => console.log('nextTick'));Promise.resolve().then(() => console.log('promise microtask'));queueMicrotask(() => console.log('queueMicrotask'));console.log('sync');Output: sync, nextTick, promise microtask, queueMicrotask, timeout 0, immediate.
Rule: sync code, then nextTick, then microtasks, then the loop phases. But look at what changes in other places (all of these I ran):
| Where the same calls run | Order I got |
|---|---|
| CommonJS main file | sync, nextTick, promise, then timeout 0, immediate |
ES module file (.mjs) | sync, promise, nextTick, then timeout 0, immediate |
Inside an I/O callback (fs.readFile callback) | nextTick, promise, immediate first, then timeout 0 |
Two things to learn from the table:
- In an ES module, the promise runs before
nextTick. The reason is that the module itself is evaluated inside a promise job, so the microtask queue is already being drained when your code adds to the nextTick queue. This is the best "trap" of the topic. (The cause is my reading of the behaviour; the order is what I measured.) - Inside an I/O callback,
setImmediatealways beatssetTimeout(0). The loop is in the poll phase, and the next phase is check (immediates), then the next iteration reaches timers.
And in the main module the order of setTimeout(0) and setImmediate is not guaranteed. I measured it two ways. From a file (node order1.js, 20 runs) the timeout came first in 20 of 20. With node -e (100 runs, twice) the immediate came first in 93 and 94 of 100. The reason is a race: setTimeout(f, 0) is really 1 ms, and whether 1 ms has already passed when the loop first checks timers depends on how long the start-up took. Never depend on it.
process.nextTick can starve I/O. It runs before the loop continues, so a function that calls process.nextTick(itself) forever blocks timers and I/O. In a test, a self-calling nextTick ran about 650,000 times in 200 ms, and a 10 ms timer waited for all of them. A recursive setImmediate does not do this, because each immediate runs in the next loop turn. Also note that since v22.7.0 the docs mark process.nextTick as Stability 3 - Legacy and say "use queueMicrotask() instead". It still works, and Node itself uses it inside, but new code should prefer queueMicrotask or a promise.
Part 3. The threadpool and what uses it
The libuv docs say the pool is used for all file system operations, getaddrinfo and getnameinfo, and for user code queued with uv_queue_work. Its default size is 4, set with the UV_THREADPOOL_SIZE environment variable, and the absolute maximum is 1024. The Node CLI docs list what uses it in Node:
| Uses the threadpool | Does not use it |
|---|---|
All fs APIs except the file watchers and the explicit ...Sync ones | Network sockets (TCP, HTTP): they use the OS poller on the loop thread |
Async crypto: pbkdf2, scrypt, randomBytes, randomFill, generateKeyPair | dns.resolve*(): it makes a real network query on the loop |
dns.lookup() (this is what http and net call for a host name) | Timers, promises |
zlib (all except the sync functions) | Worker threads (they have their own threads) |
The Node dns docs warn that dns.lookup() "is implemented as a synchronous call to getaddrinfo(3) that runs on libuv's threadpool" and can have surprising effects; dns.resolve() does not use the pool.
I ran 8 crypto.pbkdf2 jobs at the same time (each is CPU work of about 285 ms when run alone on my machine):
const crypto = require('node:crypto');const t0 = performance.now();for (let i = 1; i <= 8; i++) { crypto.pbkdf2('pw', 'salt', 400_000, 64, 'sha512', () => console.log(`job ${i} finished at ${Math.round(performance.now() - t0)} ms`));}UV_THREADPOOL_SIZE | Total time for 8 jobs (my machine) | What I saw |
|---|---|---|
| 1 | about 2,100 to 2,300 ms | one after another: 8 times the single job |
| 4 (default) | about 560 to 680 ms | two waves: 4 jobs finish at about 280 ms, the other 4 at about 550 to 560 ms |
| 8 | about 600 to 730 ms | no gain over 4 |
The conclusions that hold on any machine: (1) the pool really is a queue with a fixed number of workers (two waves with 4 threads); (2) a bigger pool does not help CPU-bound work once it passes the number of CPU cores (my machine has 4, and 8 threads gave no speedup); (3) a bigger pool helps when threads mostly wait (slow disk, slow DNS). The exact milliseconds are mine.
The real-world bug is this: a slow thing in the pool (many big file reads, a slow DNS server) delays unrelated things in the pool, such as password hashing. If all four threads wait for a DNS server, every fs.readFile waits too. Fixes: raise UV_THREADPOOL_SIZE (it must be set when the process starts, because the docs say setting it inside the process "is not guaranteed to work"), avoid dns.lookup for hot paths (use an address, a cache, or dns.resolve), or move CPU work to worker threads.
Part 4. Streams and backpressure
A stream moves data in chunks, so a 2 GB file does not need 2 GB of memory. There are four kinds: Readable (a source), Writable (a destination), Duplex (both, like a socket) and Transform (a duplex that changes data, like gzip).
Each stream has an internal buffer. The highWaterMark is the size at which it says "full". The v22.22.0 docs say it defaults to 65536 bytes (64 KiB), or 16 objects in object mode. I checked with code: new Readable().readableHighWaterMark and new Writable().writableHighWaterMark both gave 65536, and an object-mode stream gave 16. This default was raised in v22.0.0 (the docs list the change "bump default highWaterMark"); in older versions it was 16 KiB. That is a nice trap if someone says "16 KB".
Backpressure
writable.write(chunk) returns false when the internal buffer has reached the highWaterMark. The stream still accepts the data (it does not throw), but it is telling you to stop writing until the 'drain' event. If you ignore it, the data piles up in memory. I wrote 200 MB (3,200 chunks of 64 KiB) into a slow writable (1 ms per chunk) in two ways:
// ignoring the signal // respecting itslow.write(chunk); if (!slow.write(chunk)) await once(slow, 'drain');| Way | write() returned false | Peak data buffered inside the stream (writableLength) |
|---|---|---|
| Ignore the return value | 3,200 times | 199.8 MiB |
Wait for 'drain' | 3,200 times | 0.1 MiB |
Both finished in about 3.7 seconds, because the writable was the bottleneck either way. The only difference was memory: the whole 200 MB sat in RAM when I ignored the signal. (The conclusion is the buffer size, which does not depend on my machine.)
You rarely write this by hand. pipe() and pipeline() do it for you, and so does for await (const chunk of readable).
pipe() or pipeline()
readable.pipe(writable) handles backpressure, but not errors: the Node docs say that if the readable emits an error, the destination is not closed automatically and you must close every stream yourself. I ran it with a source that fails:
- With
src.pipe(dst)the error went to the source's handler, anddst.destroyedstayedfalse(an open stream: a leak). - With
pipeline(src, dst, callback)the callback got the error anddst.destroyedwastrue.
So use pipeline (from node:stream, or the promise version from node:stream/promises):
import { pipeline } from 'node:stream/promises';import { createReadStream, createWriteStream } from 'node:fs';import { createGzip } from 'node:zlib';await pipeline(createReadStream('big.log'), createGzip(), createWriteStream('big.log.gz'));The await finishes only when everything is flushed, and errors close all streams. Compression here uses the threadpool (Part 3).
Part 5. worker_threads, cluster and child_process
All three give you more than one thread of work. They solve different problems.
worker_threads | cluster | child_process | |
|---|---|---|---|
| What it creates | Another thread in the same process, with its own V8 and event loop | Several processes (made with child_process.fork()) that share a listening port | Any process: another program, or a Node script |
| Memory | Can share memory (SharedArrayBuffer) or transfer buffers | Separate memory | Separate memory |
| Talk to each other | postMessage (structured clone), workerData | IPC messages | stdio pipes, IPC (for fork) |
| Typical use | CPU-heavy JavaScript (image resize, parse, hash) without blocking the loop | Use all CPU cores for a server, and isolate crashes | Run git, ffmpeg, a shell command; isolate untrusted work |
| Cost to start | Lower | Higher (a full Node process each) | Higher |
| Stability (v22.22.0 docs) | Stable | Stable | Stable |
The worker_threads docs say workers "are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work", because the built-in async I/O is already more efficient. They also list that in a worker process.exit() stops only that thread, signals are not delivered to it, and process.env is a copy (unless you pass SHARE_ENV).
Experiment: a CPU job on the main thread or in a worker
I ran fib(38) (a deliberately slow recursive function) once on the main thread and once in a worker, while a 10 ms timer measured how late it fired:
// ticker: worst lag = how much later than 10 ms a tick ranconst ticker = setInterval(() => { const now = performance.now(); worstLag = Math.max(worstLag, now - last - 10); last = now;}, 10);// mode 'main': fib(38) here// mode 'worker': new Worker(__filename) runs fib(38) and posts the result backWhere fib(38) ran | Job time | Worst event loop lag |
|---|---|---|
| Main thread | 788 ms and 980 ms (two runs) | 789 ms and 981 ms: the loop was frozen for the whole job |
| Worker thread | 882 ms and 936 ms | 3 ms and 8 ms |
The worker is not faster (a little slower here, because it must start a new V8). The point is that the main loop stayed responsive. For a server this decides whether other users wait. To watch lag in production, use monitorEventLoopDelay() from node:perf_hooks: in a test, a 300 ms blocking loop showed a max delay of 308 ms.
In real servers do not create a worker per request. Keep a pool of workers (libraries such as piscina do this) and send them jobs.
How cluster shares a port
The cluster docs describe two ways. The default on all platforms except Windows is round robin: the primary process accepts connections and hands them to workers in turn. The other way (SCHED_NONE) gives the listening socket to the workers and lets the OS decide; the docs warn that this is often very unbalanced and report loads "where over 70% of all connections ended up in just two processes, out of a total of eight". You choose with cluster.schedulingPolicy or NODE_CLUSTER_SCHED_POLICY (rr or none). I started 4 workers and made 40 requests, each on a new connection, and counted per worker:
| Policy | Requests per worker |
|---|---|
| round robin (default) | [10, 10, 10, 10] in all runs |
NODE_CLUSTER_SCHED_POLICY=none | [6, 7, 9, 18], [1, 12, 20, 7] and others: uneven |
Today many teams run one Node process per container and let the platform (for example Kubernetes) run several copies, instead of using cluster. Both are valid; the difference is who manages the copies. Remember that workers in a cluster do not share memory, so an in-memory cache or a rate limiter counter is per process.
Part 6. AsyncLocalStorage
Passing a request id through every function is tedious. AsyncLocalStorage (from node:async_hooks) keeps a store that follows the async chain started inside run(). The module is Stable in the v22.22.0 docs.
const { AsyncLocalStorage } = require('node:async_hooks');const als = new AsyncLocalStorage();const log = (msg) => console.log(`[req ${als.getStore()?.id ?? '-'}] ${msg}`);async function db() { await sleep(20); log('after db'); } // never receives the idhttp.createServer((req, res) => { als.run({ id: req.headers['x-id'] }, async () => { // the store lives for this call tree log('start'); await db(); await sleep(5); log('end'); res.end('ok'); });});I sent three requests at the same time (ids a, b, c). Every log line had the right id, even after the awaits, and a line logged outside of run() printed -. This is the right tool for request ids in logs, trace ids, and the current user in a transaction helper.
Gotchas:
- The context is lost when you break the async chain. If you put a callback in your own queue and call it later from somewhere else,
getStore()returnsundefined. In my test a function queued insideals.run('A', ...)and called from a later timer sawundefined. A function wrapped withAsyncLocalStorage.bind(fn)saw'B'. (bindandsnapshotexist since v19.8.0 and v18.16.0.) Connection pools and custom queues are the usual places where it is lost. - Do not use it as a global variable. Keep what you store small and read-only.
- In the v22.22.0 docs
run()andgetStore()are stable, butenterWith(),exit()anddisable()are marked Experimental. Preferrun(). - Do not confuse it with the old
async_hooks.createHook(). Theasync_hookspage is marked Experimental ("Please migrate away from this API, if you can") and says "We strongly discourage the use of theasync_hooksAPI".
Part 7. Memory and garbage collection
The basics
V8 splits the heap into a young generation (new, small objects; cleaned often and fast by a "Scavenge") and an old generation (objects that survived; cleaned less often by a "Mark-Compact"). Most objects die young, so this is efficient. I turned on node --trace-gc and saw lines like Scavenge 4.4 (5.1) -> 3.7 (6.1) MB, 1.28 ms and, with big live data, Mark-Compact 89.3 (108.5) -> 85.5 (121.6) MB, 302 ms. Note that a Mark-Compact can take hundreds of milliseconds on a large heap.
process.memoryUsage() returns five numbers (from the docs):
| Field | Meaning |
|---|---|
heapTotal, heapUsed | V8's heap: reserved and used |
external | Memory of C++ objects tied to JavaScript objects |
arrayBuffers | Memory of ArrayBuffers and SharedArrayBuffers, including every Buffer |
rss | The memory the whole process occupies (heap, code, buffers, native) |
So a leak of Buffers shows in arrayBuffers and rss more than in heapUsed. The limit for the old generation depends on your machine's memory (on my 16 GB machine v8.getHeapStatistics().heap_size_limit was about 8,240 MB). You can change it with --max-old-space-size=SIZE (in MiB). Setting it to 64 gave a reported limit of 112 MB, so the number you set is not exactly the number you get.
When the heap is full, the process dies: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. I made one happen with --max-old-space-size=24, and the process ended with exit code 134 (an abort).
A leak you can see
A cache with no limit is the most common leak. I added 50,000 entries per round to a Map that is never cleaned, forced a full GC (node --expose-gc) and printed the heap:
round 1: entries=50000 heapUsed after full GC = 18 MB (start 4 MB)round 2: entries=100000 heapUsed after full GC = 32 MBround 3: entries=150000 heapUsed after full GC = 49 MBround 4: entries=200000 heapUsed after full GC = 62 MBThe key signal is memory that still grows after a full GC. Memory that goes up and then down is normal. Memory that keeps a rising floor is a leak. The usual causes: unbounded caches and arrays, listeners added but never removed, timers never cleared, closures that keep big objects, and global maps keyed by request.
Tools: --inspect and heap snapshots
node --inspectstarts the inspector. The default address is127.0.0.1:9229. I started it on port 9333 andcurl http://127.0.0.1:9333/json/listreturned the target, so Chrome DevTools can attach throughchrome://inspect. The docs warn that binding the inspector to a public address is insecure: anyone who can reach it can run code in your process. Use an SSH tunnel instead.v8.writeHeapSnapshot()writes a snapshot file. For a heap with 100,000 small objects the file was 20.5 MB and writing it took 1.2 seconds. The docs warn that it blocks the event loop and that it needs memory about twice the size of the heap, with a risk of the OOM killer ending the process. Do it on one instance that you have removed from the load balancer.--heapsnapshot-signal=SIGUSR2writes a snapshot when the process gets that signal (kill -USR2 <pid>). I did this and got aHeap.<date>.<pid>...heapsnapshotfile. It is a runtime switch you can keep on in production if you accept the cost above.--heapsnapshot-near-heap-limit=Nwrites snapshots as the heap approaches its limit. The docs mark it Experimental. In my OOM test it wrote a 74 MB snapshot just before the crash.
The method for a leak: take a snapshot, run traffic, take a second snapshot, then in DevTools (Memory tab) use the Comparison view to see which object types grew, and follow the retainers (what keeps them alive) up to a root. Three snapshots (before, after, after again) make growth clear. For Buffer leaks watch arrayBuffers. In production also graph heapUsed, rss, GC pause time and event loop lag.
Part 8. Built-in tools in Node 22
The test runner (node:test)
No install needed. Files named *.test.js, *-test.js, *_test.js, test-*.js, test.js or inside a test/ folder are found automatically by node --test (from the docs). Note that *.spec.js is not on the list: I ran a a.spec.js and a a.test.js and only the .test.js ran. (In 22.22.0 the same patterns for .ts files are also matched unless you pass --no-experimental-strip-types.)
const { describe, it, mock } = require('node:test');const assert = require('node:assert/strict');const { total } = require('./price.js');describe('total()', () => { it('multiplies price by qty and applies the rate', () => { const rate = mock.fn(() => 2); // a function that records its calls assert.equal(total([{ price: 5, qty: 3 }], rate), 30); assert.equal(rate.mock.callCount(), 1); }); it.todo('discount codes');});What I saw: with --test-reporter=spec the output has check marks and a summary (tests 5, pass 4, todo 1). The default reporter depends on the output: the docs say spec is used when stdout is a TTY and tap otherwise, and when I piped the output I got TAP. A failing assert.deepEqual printed a diff of the two values and the exit code was 1, which is what CI needs. --test-name-pattern="empty" ran only the matching test. --experimental-test-coverage printed a table of line, branch and function coverage.
Stability, from the docs: the module is Stable. These parts are not: mock timers (mock.timers) are Experimental (Node printed an ExperimentalWarning), node --test --watch mode and code coverage are marked Experimental in the test runner page, and mock.module() is Early development and needs --experimental-test-module-mocks. Snapshot testing (t.assert.snapshot() with --test-update-snapshots) printed no warning when I ran it; the CLI docs say it is no longer experimental since v22.13.0 (the test page gives v23.4.0 for the same change, so the docs disagree a little). For React components, the DOM and rich mocking, Vitest or Jest are still the better fit (Lesson 21); the built-in runner is good for libraries, scripts and plain Node services.
--watch
node --watch app.js restarts the process when the entry file or any file it imports changes. It has been Stable since v22.0.0 (the CLI docs: "Watch mode is now stable"). I changed an imported file and the output was run 1, Restarting 'app.js', run 2. --watch-path=./src restricts what is watched (and turns off watching of imports). The docs say this option is only supported on macOS and Windows; on my Linux machine it worked, both for a single file and for a changed file inside a nested folder. I would trust the docs for portability and test on your own system. It cannot be combined with --eval or the REPL, and it needs a file argument.
A close relative is node --run <script>, which runs a script from package.json (added in v22.0.0). I ran node --run hi and it printed the script's output. The docs show no stability banner for it in v22.22.0. --env-file=.env is also no longer experimental (since v22.21.0).
The permission model
Run with --permission and Node denies, by default, file system reads and writes, child processes, worker threads, WASI and native addons. You open specific doors with flags. It is Stable since v22.13.0 (CLI docs). I ran the same script three ways:
| Command | Result |
|---|---|
node p.js | everything worked; process.permission does not exist, so using it crashed |
node --permission p.js | read, write, spawn and worker all failed with ERR_ACCESS_DENIED (FileSystemRead, FileSystemWrite, ChildProcess, WorkerThreads) |
node --permission --allow-fs-read=<data.txt> --allow-fs-read=<p.js> --allow-child-process p.js | data.txt readable, sec/s.txt still denied, spawn allowed |
process.permission.has('fs.read', path) checks at run time. Node also printed a SecurityWarning for --allow-child-process: "must be used with extreme caution. It could invalidate the permission model". Stability of the individual flags: --allow-fs-read and --allow-fs-write are stable, but --allow-child-process and --allow-worker are labelled "Active development".
The permission model is a safety net, not a sandbox for hostile code. The docs list limits: symlinks are followed even outside the allowed paths, existing file descriptors bypass it, and it does not stop access "through other means, such as through the node:sqlite module". I checked that last one: with --permission and read access only to the script, new DatabaseSync('/tmp/x-perm-test.db') opened and wrote a file anyway. For untrusted code use a container or a VM.
node:sqlite
A SQLite database inside Node, with no extra package. Added in v22.5.0; since v22.13.0 it works without a flag (--no-experimental-sqlite turns it off). Its stability is 1.1 Active development, and Node prints an ExperimentalWarning when you load it. The API is synchronous (DatabaseSync, StatementSync):
const { DatabaseSync } = require('node:sqlite');const db = new DatabaseSync(':memory:'); // or a file pathdb.exec('CREATE TABLE todo (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0) STRICT');const ins = db.prepare('INSERT INTO todo (title) VALUES (?)');ins.run('write lesson'); // { lastInsertRowid: 1, changes: 1 }db.prepare('SELECT * FROM todo WHERE id = ?').get(1); // one row (null-prototype object)db.prepare('SELECT id, title FROM todo ORDER BY id').all(); // array of rowsA NOT NULL violation threw an error with code ERR_SQLITE_ERROR. The bundled SQLite version was 3.50.4. The module exports DatabaseSync, StatementSync, constants and backup. Because every call is synchronous, a slow query blocks the event loop, so it suits scripts, tools, tests and small local-first apps, and not a busy server (use a worker thread, or a client-server database). The docs list no transaction helper (only a read-only database.isTransaction flag), so I use db.exec('BEGIN') and COMMIT myself.
fetch and undici
Global fetch is Stable since v21.0.0 (it was un-flagged in v18). It is built on undici, an HTTP/1.1 client written for Node; process.versions.undici was 6.23.0 here. You cannot require('undici') unless you install it: only fetch, Request, Response, Headers, FormData and WebSocket are global. To tune connections (pool size, keep-alive, proxy), install the undici package and pass a dispatcher: fetch(url, { dispatcher: new Agent({...}) }) (shown in the Node docs). What I checked:
- Keep-alive works by default. 20 sequential requests to a local server opened only 2 sockets.
- A timeout needs a signal.
fetch(url, { signal: AbortSignal.timeout(300) })against a 2-second endpoint rejected withTimeoutErrorafter 304 ms. - HTTP errors do not reject. A 404 gave
ok === falseand the promise resolved. Checkres.ok. - Network errors reject with
TypeError: fetch failed; the real reason is inerror.cause. res.bodyis a webReadableStream(not a Node stream). Convert withReadable.fromWeb()when you need to pipe it.
Part 9. Graceful shutdown
When a platform stops your app (a deploy, a scale-down), it sends a signal. Platforms such as Docker and Kubernetes send SIGTERM first and SIGKILL after a grace period (see their docs; I did not re-check them here). SIGKILL cannot be caught ("cannot have a listener installed", Node docs), so everything must be done in the time between.
Facts from the Node docs that I confirmed by running:
- A process with no
SIGTERMhandler exits at once, with code128 + signal number. I got 143 forSIGTERMand 130 forSIGINT. - Once you add a listener, the default behaviour is removed: Node no longer exits by itself. You must exit.
- The
'exit'event allows only synchronous work.'beforeExit'is not emitted forprocess.exit()or an uncaught exception. server.close()stops accepting new connections and, since v19.0.0, also closes idle keep-alive connections. It calls its callback when the remaining connections are gone.server.closeIdleConnections()andserver.closeAllConnections()exist (added in v18.2.0).- Default timeouts of the HTTP server (v22 docs):
keepAliveTimeout5,000 ms,requestTimeout300,000 ms,headersTimeoutthe minimum ofrequestTimeoutand 60,000 ms.
The pattern I ran:
let shuttingDown = false;function shutdown(signal) { if (shuttingDown) return; // ignore a second signal shuttingDown = true; console.log(`${signal} received: stop accepting, wait for in-flight requests`); server.close((err) => { // called when every connection is gone // close the database pool, flush logs, stop queue consumers here process.exit(err ? 1 : 0); }); server.closeIdleConnections(); // keep-alive sockets with no request setTimeout(() => server.closeAllConnections(), 5000).unref(); // hard limit}process.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));I started a request that needed 1.5 seconds, sent SIGTERM after 0.3 seconds, then tried a new request. The new request was refused (curl exit 7), the slow request finished and got its answer, then the server logged server closed, cleaning up, and the process exited with code 0.
The order that matters: (1) stop taking new work (and tell the load balancer you are not ready, if you have a health check), (2) finish current work, (3) close databases, queues and other resources, (4) exit, (5) have a time limit that forces the exit, so you never hang. For queue workers, stop pulling new jobs and let the current job finish.
Stable or experimental in Node v22.22.0
Taken from the v22.22.0 docs (and my runs where noted):
| Feature | Status |
|---|---|
node:test, node --test | Stable |
mock.timers, node --test --watch, coverage | Experimental |
mock.module() (--experimental-test-module-mocks) | Early development (1.0) |
--watch, --watch-path | Stable (since v22.0.0) |
--permission, --allow-fs-read, --allow-fs-write | Stable (since v22.13.0) |
--allow-child-process, --allow-worker | Active development (1.1) |
node:sqlite | Active development (1.1); no flag needed since v22.13.0 |
Global fetch (undici) | Stable (since v21.0.0) |
worker_threads, cluster, child_process | Stable |
AsyncLocalStorage run, getStore | Stable |
AsyncLocalStorage enterWith, exit, disable | Experimental |
process.nextTick | Legacy (since v22.7.0) |
--env-file | No longer experimental (since v22.21.0) |
--heapsnapshot-near-heap-limit | Experimental |
--inspect, --heapsnapshot-signal, --max-old-space-size | No experimental label |
Running .ts files (type stripping) | On by default since v22.18.0; Release candidate (1.2) |
On the last row: node t.ts with const x: number = 41; console.log(x + 1) printed 42 on this install with no flag. Type stripping only removes types; it does not support TypeScript features that need code generation, so see the docs before using it for a real project.
Common mistakes
- Relying on the order of
setTimeout(0)andsetImmediatein the main module. - Using
process.nextTickrecursively, or in new code whenqueueMicrotaskwould do. - Believing Node is single threaded, and then being surprised that a slow DNS lookup delays file reads.
- Raising
UV_THREADPOOL_SIZEabove the CPU count to speed up CPU-bound work. - Ignoring the return value of
write(), and buffering a whole file in memory. - Using
pipe()without error handling, and leaking the destination stream. - Running CPU work on the main thread inside a request handler.
- Adding
SIGTERMorSIGINThandlers that never callprocess.exit(), so the process hangs. - Exposing
--inspecton a public address, or taking a heap snapshot on a busy instance with little spare memory. - Treating the permission model as a sandbox for untrusted code.
- Running
node:sqliteheavy queries on the main thread of a busy server. - Forgetting that
fetchdoes not reject on HTTP error status, and has no timeout unless you add one.
Interview questions
Explain the phases of the Node event loop. (Must know)
One turn of the loop goes through: timers (due setTimeout and setInterval callbacks), pending callbacks, idle and prepare (internal), poll (wait for and run I/O callbacks), check (setImmediate callbacks), and close callbacks. In the poll phase the loop may sleep, but it computes the sleep: zero if immediates are waiting, otherwise until the next timer is due. Promises and process.nextTick are not phases: their queues are emptied after each callback, nextTick first, then microtasks. I confirmed it by running two timers that each queued a tick and a promise: the output was timer 1, its tick, its microtask, and only then timer 2.
What is the output order of nextTick, a promise, setTimeout(0) and setImmediate? (Must know)
In a CommonJS main file: the sync code first, then nextTick, then the promise, then setTimeout(0) and setImmediate (the last two in a race). Inside an I/O callback, setImmediate always comes before setTimeout(0). In an ES module, the promise runs before nextTick, because the module is evaluated inside a promise job. I ran all three cases and I would say the third one in an interview, because most people do not know it.
setTimeout(fn, 0) or setImmediate(fn): which runs first? (Trap)
It depends where you call them. From the main module it is not guaranteed: a timer of 0 ms is really 1 ms, and whether the loop sees it as due depends on start-up time. From a file I got the timeout first 20 times out of 20; with node -e I got the immediate first in 93 and 94 of 100 runs. Inside an I/O callback the immediate is always first, because the check phase comes right after poll. So I never rely on it, and I use setImmediate only when I want "after the current I/O".
Is Node.js single threaded? (Trap)
Your JavaScript runs on one thread, so two callbacks never run at the same moment in the same isolate. The process has more threads: V8 uses helper threads for compiling and GC, and libuv has a threadpool (4 threads by default) for file system calls, dns.lookup, zlib and some crypto. Network I/O does not use the pool; it uses the OS poller (epoll on Linux) on the loop thread. And worker_threads can add more JavaScript threads. In my pbkdf2 test, 8 jobs ran in two waves of 4, which shows the pool at work while my code was single threaded.
What uses the libuv threadpool, and what do you do when it is the bottleneck? (Senior signal)
Per the Node and libuv docs: all fs calls except watchers and the sync ones, dns.lookup, async crypto such as pbkdf2, scrypt and randomBytes, and zlib. Sockets and dns.resolve do not use it. It is a fixed queue, so slow work delays unrelated work: I ran 8 hashes with a pool of 1, 4 and 8; the totals were about 2.1 s, 0.6 s and 0.6 s, so more threads than CPU cores did not help CPU work. When it is the bottleneck I first find what is clogging it. If threads are waiting (DNS, slow disk), I raise UV_THREADPOOL_SIZE at start-up, cache DNS, or use dns.resolve. If it is CPU work, I move it to worker threads or another service.
dns.lookup or dns.resolve: what is the difference? (Trap)
dns.lookup calls the operating system function getaddrinfo, like most programs do. It reads /etc/hosts and system settings, and it runs on the libuv threadpool, so a slow resolver can block unrelated pool work. http, net and fetch use it internally for host names. dns.resolve* sends a real DNS query over the network, always asynchronously, and does not use the pool, but it ignores /etc/hosts. In a high-traffic client I would cache lookups or use an address to avoid filling the pool.
Can process.nextTick hurt a server? What do you use instead? (Trap)
Yes. The nextTick queue is emptied before the event loop continues, so a callback that keeps scheduling nextTick blocks timers and I/O. In my test a self-scheduling tick ran about 650,000 times in 200 ms, and a 10 ms timer waited for all of it. Since v22.7.0 the docs label it Legacy and say to use queueMicrotask(). Microtasks have the same starving risk if they re-queue themselves, so for "yield to I/O" I use setImmediate.
What is backpressure and how does Node handle it? (Must know)
Backpressure is the consumer telling the producer to slow down. In Node, writable.write() returns false when the internal buffer reached the highWaterMark, and the producer should wait for 'drain'. pipe(), pipeline() and for await do this for you. I tested it: writing 200 MB into a slow writable, ignoring the false buffered 199.8 MiB in memory, and waiting for 'drain' buffered 0.1 MiB, and both took the same time. So backpressure protects memory, not speed.
pipe() or pipeline()? (Senior signal)
pipeline(). pipe() handles backpressure but not errors: if the source fails, the destination stays open and you must destroy every stream yourself. I ran it: after the source error, dst.destroyed was false with pipe() and true with pipeline(), and pipeline also gave me one callback (or one rejected promise in node:stream/promises) when everything is finished or failed. I use await pipeline(...) for files, gzip and HTTP bodies.
What is the default highWaterMark? Did it change? (Trap)
In Node 22 it is 65,536 bytes (64 KiB) for byte streams and 16 for object mode, and the docs list the change "bump default highWaterMark" in v22.0.0; before that it was 16 KiB. I checked new Readable().readableHighWaterMark and got 65536. You can change it per stream with the option, or globally with stream.setDefaultHighWaterMark(). Bigger buffers use more memory per stream, which matters when you have thousands of connections.
workerthreads, cluster or childprocess: how do you choose? (Must know)
For CPU-heavy JavaScript that should not block the loop, worker_threads: they are threads in the same process, can share memory with SharedArrayBuffer, and start faster than processes. In my test the same fib(38) froze the loop for about 790 ms on the main thread and about 3 to 8 ms in a worker. For using every CPU core with a server that shares a port, cluster (processes, round robin) or, more common now, several containers behind a load balancer. For running another program, or isolating risky work, child_process. Workers do not help much with I/O work, because Node's async I/O is already efficient.
How does cluster distribute connections? What can go wrong? (Senior signal)
The default on all platforms except Windows is round robin: the primary accepts connections and hands them to workers in turn. The alternative, SCHED_NONE, lets the OS pick, and the docs warn it can be very unbalanced. I ran 4 workers and 40 requests: round robin gave [10, 10, 10, 10] every time, and none gave results like [1, 12, 20, 7]. What goes wrong: workers do not share memory, so in-memory sessions, caches and rate limits are per process; a worker crash needs a restart policy; and the primary is a single point that does the distribution.
What is AsyncLocalStorage and when does the context get lost? (Senior signal)
It stores a value that follows an async call chain started inside als.run(store, fn). It is how I carry a request id or a user into logs and database helpers without passing arguments. I tested three concurrent requests and each log line had the right id, even after awaits. The context is lost when the chain is broken: a callback stored in my own queue and called later from another place saw undefined; wrapping it with AsyncLocalStorage.bind() fixed it. Pools and custom queues are the usual cause. I prefer run(), because enterWith() and exit() are experimental in v22.
How does V8 garbage collection work, and what are heapUsed, external and rss? (Must know)
V8 uses generations. New objects are in the young generation, cleaned often and quickly by a Scavenge. Objects that survive move to the old generation, cleaned by Mark-Compact, which is slower; I saw one take about 300 ms on a large heap. heapUsed is the live V8 heap. external is native memory tied to JS objects, arrayBuffers includes every Buffer, and rss is the whole process. A Buffer leak therefore shows in arrayBuffers and rss while heapUsed looks fine. The old-space limit depends on machine memory and can be set with --max-old-space-size; when it is exceeded the process aborts (I got exit code 134).
A Node service's memory grows until it is killed. How do you find the leak? (How would you debug)
First I confirm it is a leak: memory that keeps a rising floor after GC, with heapUsed, arrayBuffers and rss graphed to see which one grows. Then I take heap snapshots from one instance (taken out of the load balancer, because a snapshot blocks the loop and needs about twice the heap in memory): I can start with --heapsnapshot-signal=SIGUSR2 and send the signal, or call v8.writeHeapSnapshot(). I take one, run traffic, take another, and compare them in the DevTools Memory tab to see which types grew and what retains them. Usual suspects: an unbounded Map, listeners that are never removed, timers, closures. In my demo a Map that never evicts grew the post-GC heap from 18 MB to 62 MB in four rounds. The fix is a size limit or TTL, and a graph that shows it is flat afterwards.
What can the built-in test runner do, and what is still experimental? (Must know)
node --test finds *.test.js-style files (not *.spec.js), runs each file in its own process, and has describe, it, hooks, mock.fn, reporters, --test-name-pattern, and a non-zero exit code on failure. The module is Stable in v22.22.0. Mock timers, --watch inside the runner and coverage are Experimental, and mock.module() is Early development and needs a flag. I use it for libraries, scripts and Node services where I want no dependencies. For React components and rich mocking I still choose Vitest or Jest (Lesson 21).
What does the permission model protect you from? What does it not? (Trap)
With --permission, Node denies file system, child process, worker, WASI and addon access unless you allow it with flags such as --allow-fs-read. It is Stable since v22.13.0, although --allow-child-process and --allow-worker are still Active development. I ran a script under it and got ERR_ACCESS_DENIED for reads, writes and spawn. It limits mistakes and compromised dependencies calling the usual APIs. It is not a sandbox for hostile code: the docs say symlinks are followed, open file descriptors bypass it, and other modules can sidestep it. I proved the last one: node:sqlite still created a database file outside the allowed paths. For untrusted code I use a container or a VM.
When would you use node:sqlite? (Senior signal)
For scripts, CLI tools, tests, caches and small local-first apps: no install, no native build, and a real SQL engine (SQLite 3.50.4 in my run). I know its limits: its stability is "Active development" and Node prints an ExperimentalWarning, it is synchronous so a slow query blocks the event loop, and I have to manage transactions with SQL. It works without a flag since v22.13.0. For a busy multi-user server I would use PostgreSQL, or run SQLite in a worker thread.
How does fetch work in Node, and what are its traps? (Must know)
Global fetch is stable since v21 and is built on undici, an HTTP/1.1 client; process.versions.undici shows the version. It reuses connections by default (20 sequential calls used 2 sockets in my test). The traps: it does not reject on HTTP 404 or 500, so I check res.ok; it has no short timeout by default, so I pass AbortSignal.timeout(ms) (it rejected with TimeoutError after 304 ms in my test); network failures give TypeError: fetch failed with the real error in cause; and the body is a web ReadableStream. To change pool size or proxies I install the undici package and pass a dispatcher, because the package is not importable unless installed.
How do you shut down a Node server gracefully? (Must know)
I handle SIGTERM and SIGINT (and ignore a second signal). On the signal I stop accepting new connections with server.close(), close idle keep-alive connections, let in-flight requests finish, then close the database pool, queue consumers and logs, and call process.exit(0). I add a timer that force-closes connections after a limit, so shutdown cannot hang. I tested it: a 1.5-second request that started before SIGTERM still got its answer, and a new connection was refused. I remember that adding a signal listener removes Node's default exit, so I must exit myself; that SIGKILL cannot be caught; that the 'exit' event can only do synchronous work; and that a platform's grace period is the time I have.
Key takeaways
- Node runs your JavaScript on one thread, but the process also has V8 helper threads and a libuv threadpool. Network I/O uses the OS poller, not the pool.
- Order: sync code, then
nextTick, then microtasks (after each callback), then the loop phases. In an ES module, promises run beforenextTick.setTimeout(0)againstsetImmediatein the main module is a race. - The threadpool has 4 threads by default and serves
fs,dns.lookup,zliband some crypto. More threads than CPU cores do not speed up CPU work. - Respect
write()returningfalse, or usepipeline(). Backpressure saves memory, not time. The defaulthighWaterMarkis 64 KiB since v22.0.0. - Use worker threads to keep the loop responsive,
clusteror containers to use all cores, andchild_processfor other programs. AsyncLocalStorage.run()gives per-request context; it is lost if you break the async chain.- A leak is memory that rises after GC. Compare heap snapshots, and remember that snapshots block the loop and need about twice the heap.
- In v22.22.0:
node:test,--watch,--permissionandfetchare stable;node:sqliteis in active development;process.nextTickis legacy. The permission model is not a sandbox. - Shut down on
SIGTERM: stop accepting, finish in-flight work, close resources, exit, with a time limit.
What's next
Go back to Lesson 06 and Lesson 16 and try to explain each ordering puzzle in terms of phases and queues. For measuring what these internals cost in a real request path, read Lesson 32.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- The Event Loop: Timers, Promises and the Poll Phase · Node.js From Zero
- Browser Rendering and the Event Loop in Practice · The Browser Contract
- The Mental Model: Snapshots, Commits and Branches · Git & GitHub
Related series