Not a thread. A second Node.
Part 4 ended on the constraint the whole series keeps arriving at: one thread runs your JavaScript, and whatever holds it stops everything. The obvious response is to ask for another thread. worker_threads gives you one — but not the one most people are picturing.
A worker is a whole second Node runtime inside the same process: its own V8 isolate, its own heap, its own event loop, its own module registry. It shares the process, the file descriptors and the address space. It shares no variables with you at all.
Before any of that means anything, it is worth being precise about what sits inside what. Your machine has cores; the operating system runs processes on them; a process contains threads; and Node puts a particular set of runtime pieces on some of those threads and not others.
epoll_wait is not occupying anything, which is the whole reason one thread can serve ten thousand connections.UV_THREADPOOL_SIZE, not the core count. It does not grow to 32 because the machine has 32.os.availableParallelism() counts. A large bare-metal server may carry a second package, a second socket — a word unrelated to the network sockets of Parts 0, 5, 8 and 9.worker_thread is the only box here that adds a second place your code can run, and it costs a whole V8 isolate to provide it.node command is one process. It owns a memory space and a file descriptor table, and every thread inside it shares both. Part 0 §2 is about that table.That is the distinction the name hides. Node has had extra threads since Part 5 — the libuv pool — but those cannot run your code. A worker_thread is the one kind that can, and it can only do so because it brings an entire second JavaScript runtime with it.
const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThread) {
// we are the main thread: start a second runtime running THIS SAME FILE
const worker = new Worker(__filename);
worker.on("message", (msg) => console.log("main received:", msg));
} else {
// we are inside the worker: a different isolate, running the same source
parentPort.postMessage("hello from thread " + require("worker_threads").threadId);
}
// main received: hello from thread 1Read the branch carefully, because it is the shape almost every worker example takes. The file runs twice — once as the main thread and once inside the worker — and isMainThread is how each copy finds out which it is. The two runs share no variables; the only thing crossing between them is the message.
// worker.js
const { workerData } = require("worker_threads");
workerData.n++; // mutates a COPY
// main.js
const counter = { n: 0 };
new Worker("./worker.js", { workerData: counter });
// counter.n is still 0. It was structured-cloned on the way in.That is not a limitation someone forgot to lift. Two isolates cannot share a heap, because each one has its own garbage collector and neither can be allowed to move or free an object the other is reading. Everything you send is copied, unless you say otherwise in one of exactly two ways.
A Java or Go thread shares memory by default and you opt into isolation. A Node worker is isolated by default and you opt into sharing — through transfer, which moves ownership, or SharedArrayBuffer, which is the only genuinely shared memory in the system.
Two isolates, two loops, one address space
Part 5 established what a thread is: its own instruction pointer, stack and registers, sharing the process's heap and file descriptors. A worker is that, plus an entire JavaScript runtime layered on top of it.
Everything Part 5 said about threads is still true here. The worker is a real OS thread in your process. What makes it feel unfamiliar is the isolate sitting on top of it, which un-shares the one thing threads normally share.
No closures, no shared state, no free lunch
Almost every surprise with worker_threads comes from one of three assumptions carried over from threads in other languages.
There is a fourth confusion that deserves more than a row, because the two things genuinely do sound identical: Part 5 spent a whole document on the worker pool, and this one is about worker threads. Both are extra OS threads inside your process. In every other respect they are unrelated.
The second half of that figure is the question that actually comes up in practice. Three of those six workloads are handed to the pool without you asking, one never needs a thread at all, and two are your JavaScript on your thread — where the pool sits idle and cannot help, because it runs C.
The first is the one that produces confusing bugs; the third is the one that produces slower servers. A worker is for CPU — work that occupies the thread rather than waiting on something else.
A deep copy, with rules of its own
Every postMessage, and workerData at startup, goes through the structured clone algorithm. It is a deep copy that understands more than JSON.stringify does — and refuses things JSON quietly mangles.
new Point(1,2) arrives as {x:1, y:2} — same data, no methods, and no error to tell you.Structured clone is a copy, and the cost is proportional to the size of what you send. If you are sending megabytes on every message, the boundary is your bottleneck and the next two panels are the fix.
Ownership changes hands; the bytes never move
A transfer hands the underlying memory to the other isolate rather than copying it. The operation is O(1) regardless of size — no bytes are touched, only a pointer and a bookkeeping flag.
const buf = new ArrayBuffer(100 * 1024 * 1024); // 100MB
worker.postMessage({ buf }, [buf]); // <- the transfer list
// on this side, buf.byteLength is now 0.
// the buffer is DETACHED: reading it throws.The price is that you no longer have it. Transfer is a move, not a share — after the call your ArrayBuffer is detached and any typed array viewing it is empty. That is exactly the right semantics for a pipeline where each stage hands work onward, and exactly the wrong one if you still need the data.
Transfer is free in the sense that matters: the cost does not grow with the size of the buffer. It is the difference between mailing a house and mailing its deed.
Read-modify-write, indivisibly
Atomics provides operations the CPU guarantees cannot be interleaved. Atomics.add(arr, 0, 1) is a single indivisible read-modify-write; arr[0] += 1 is three separate steps and a race.
// four workers, 100_000 increments each, shared Int32Array arr[0] += 1; // final value: somewhere under 400_000 Atomics.add(arr, 0, 1); // final value: exactly 400_000
The lost updates are not a bug in Node or in V8. They are the CPU doing exactly what Part 5 described — one instruction at a time per core, with no coordination between cores unless you ask for it.
Atomics.wait and Atomics.notify go further and let a worker sleep on a memory location without burning CPU, which is how you build a queue between workers without polling. Atomics.wait is forbidden on the main thread for the reason Part 4 spent a whole section on: it would block the loop.
If two threads can reach the same memory, every read-modify-write needs Atomics. This is the first place in the series where JavaScript stops protecting you from the machine.
It depends entirely on what is holding the thread
Part 5 asked whether libuv's pool helps, and the answer turned on whether the work was I/O or CPU. This is the same question one layer up, and the answer turns on the same distinction.
The middle case is the one worth internalising. Moving an already-async read onto a worker does not make it faster — the read was never on your thread — and now you have paid for a clone and a message round trip to learn that.
Startup is a new runtime, not a new thread
Creating an OS thread costs microseconds. Creating a worker costs milliseconds, because you are creating a V8 isolate, a context, a module registry and an event loop, and then loading your code into all of it.
The rule that follows is the same one Part 5 gave for the libuv pool, for the same reason: a fixed pool sized to your cores, fed by a queue. The difference is that here you build the pool yourself, or use one off the shelf.
A port on each side, and a queue between them
Every worker comes with one channel already wired: worker.postMessage on your side, parentPort on theirs. You can create more with MessageChannel, and — usefully — you can send a port through a port, which is how two workers end up talking to each other directly without routing through the main thread.
A throw over there is an event over here
An uncaught exception in a worker does not crash your process and does not throw anywhere you can catch it. It surfaces as an 'error' event on the worker object, and then the worker exits.
worker.on("online", () => {}); // the isolate is up
worker.on("message", (m) => {}); // it sent something back
worker.on("error", (err) => {}); // uncaught throw INSIDE the worker
worker.on("exit", (code) => {}); // always fires, even after errorIf you do not listen for 'error', the failure is silent: the worker is gone, your pending task never resolves, and the promise you handed the caller hangs forever. That is the single most common bug in home-grown worker pools.
Always pair a task with the 'exit' event, not just with 'message'. A worker that dies mid-task will never send the message you are waiting for, and only 'exit' is guaranteed to fire.
Nine claims, three of them true
These are the beliefs that produce the bugs, and most of them are correct statements about threads in some other language.
Nearly every wrong take here assumes the heap is shared. It is not, and that single fact generates the clone cost, the missing closures, the vanished prototypes and the need for Atomics.
If you remember nothing else
A worker is a second Node inside your process. Everything else follows from that.
Part 11 asks the same question at the next scale up. If a worker is a second runtime sharing one process, what does a second process share — and how do four of them end up serving one socket?