Mission 10 · Node.js Internals Campaign

worker_threads — a second V8 isolate, a second event loop, and the boundary between them

The fifth lesson. Part 4 ended on the constraint the whole series keeps arriving at: one thread runs your JavaScript. The obvious answer is to ask for another one — and what you get is not a thread in the sense you are picturing. It is a whole second Node runtime inside the same process, sharing the file descriptors and sharing no variables at all.

13 sections12 figurestwo isolatesclone · transfer · share
§ 0 · THE SHORT ANSWER FIRST

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.

Where a worker actually sits
§0 · one process on a real machine
the machine1 CPU · 32 cores
the CPU 32 cores
one node processone address space · one fd table · one pid
the main thread× 1
one OS threadruns your JavaScript
a V8 isolate — your heap, your GClibuv's event loop — Part 4~40 MB resident before your code
Uses ONE core no matter how many the machine has. Blocking it is what Parts 4, 5, 8 and 9 were about.
the libuv thread pool× 4
OS threadscannot run your JavaScript
no V8, no isolate, no heapC only — fs, dns, zlib, cryptoyou never create or address one
FOUR BY DEFAULT — UV_THREADPOOL_SIZE, not the core count. It does not grow to 32 because the machine has 32.
a worker_thread× N
OS threadsruns your JavaScript
another V8 isolate — its own heap and GCanother event loop, all six phases~40 MB each, and its own modules
You create these and address them by handle. The only extra thread that can run your code.
one core — and one core runs one thread at a time. This is one physical CPU package, the shape of almost every cloud instance and what 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.
Thirty-two cores, one process, and by default only one of those cores is running your JavaScript. The pool does not change that — it is four threads of C, and it is four whether the machine has 4 cores or 64. A 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.
everything inside the frame is one process · nothing here is a second program
Cores, processes, threads, and what Node puts on them
§0 · the same thing as a list
the machine · N cores
hardware
hw
True parallelism lives here and nowhere else. Two things run at the same instant only if they are on two cores. Everything below is about who gets to use them.
one OS process
one address space · one fd table
kernel
Your 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.
the main thread
V8 isolate + event loop + your code
main
One OS thread carrying a JavaScript runtime: a V8 isolate with your heap, and libuv's event loop from Part 4. This is the thread the whole series has been talking about.
the libuv pool · 4 threads
NO isolate · no JavaScript
node
Part 5's worker pool. These threads run C code for blocking work — file reads, DNS, crypto, zlib. They have no V8, cannot run JavaScript, and you never see them. They are threads, but they are not workers.
a worker_thread
ANOTHER V8 isolate + ANOTHER event loop
worker
What this part is about. A full second runtime on a second thread: its own heap, its own loop, its own module registry — inside the same process, sharing the same fd table, sharing no objects.
three kinds of thread in one process, and only one of them is a worker

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.

JAVASCRIPTTHE SMALLEST WORKER THAT DOES ANYTHING
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 1

Read 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.

JAVASCRIPTAND THE LINE THAT SURPRISES PEOPLE
// 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.

!Lock this in

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.

§ 1 · INSIDE ONE PROCESS

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.

What a Worker actually contains
§1 · one process, two runtimes
an OS thread
pthread_create
kernel
The same primitive Part 5 described. The kernel schedules it exactly like the main thread — no priority, no special treatment.
a V8 isolate
its own heap and GC
node
The reason nothing is shared by default. Two isolates each have their own garbage collector, and an object in one heap cannot be safely referenced from the other because either GC may move or free it at any moment.
an event loop
its own six phases
node
Everything from Part 4 again, per worker. A worker can block ITS loop with a tight loop and that is fine — it is not the loop serving your requests.
a module registry
its own require cache
node
Modules are loaded again per worker. That is real startup cost, and the reason you pool workers rather than spawning one per task.
shared: fds, address space, cwd
the process-level things
main
Part 5's per-thread/shared split still holds. What is new is that the JS HEAP is no longer in the shared column.
the heap is the one thing that moved from shared to private
!Lock this in

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.

§ 2 · THE THREE ASSUMPTIONS THAT BREAK

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.

Three assumptions that break
§2 · what does not survive the boundary
a closure over your variables
the worker cannot see your scope
worker
You pass a FILE or a source string, not a function. A function is not serialisable — it closes over a scope that does not exist in the other isolate. Libraries that appear to pass functions are stringifying them, and the closure is silently lost.
shared mutable state
workerData is a copy
worker
Structured clone on the way in and on every message. Mutating what you received changes nothing on the other side.
a thread that speeds up I/O
it will not
kernel
Parts 5, 8 and 9 already showed that I/O does not block your thread. Moving an async read onto a worker adds a clone and a thread and removes nothing, because there was nothing to remove.
the third one is the expensive mistake

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.

Two kinds of extra thread, compared
§2 · the pool is not a worker
click a row for the mechanism · then pick a workload and see which one runs it

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.

§ 3 · THE DEFAULT: EVERYTHING IS COPIED

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.

Structured clone, in one table
§3 · what survives a clone
objects, arrays, Map, Set, Date, RegExp
cloned faithfully
node
Including cyclic references, which JSON cannot represent at all.
ArrayBuffer and typed arrays
cloned — the bytes are COPIED
node
A 100MB buffer sent to four workers is 400MB of copying. This is the cost the next two panels exist to avoid.
functions, DOM-ish objects, class identity
throws DataCloneError
worker
A function cannot cross. A class instance crosses as a plain object: its prototype is gone and its methods with it.
the prototype chain
discarded, silently
worker
This is the one that bites. new Point(1,2) arrives as {x:1, y:2} — same data, no methods, and no error to tell you.
JSON loses more; clone loses less, but it still loses the prototype
!Lock this in

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.

§ 4 · MOVE IT INSTEAD OF COPYING IT

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.

JAVASCRIPTTHE SECOND ARGUMENT IS THE POINT
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.

!Lock this in

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.

§ 5 · THE ONLY GENUINELY SHARED MEMORY

Both isolates, one region, all the usual dangers

A SharedArrayBuffer is not cloned and not transferred. Both isolates hold a reference to the same physical bytes, and both may read and write them at the same instant.

Clone, transfer, share — the whole choice
§5 · the three ways across
the cost column is the reason this panel exists

This is real shared-memory concurrency, with every hazard Part 5 said threads have and JavaScript has historically been spared. Two workers incrementing the same slot will lose updates, because arr[0]++ is a read, an add and a write with no guarantee that another thread has not run in between.

Which is what Atomics is for, and why it is not optional.

§ 6 · THE ONLY SAFE WAY TO TOUCH SHARED MEMORY

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.

JAVASCRIPTTHE SAME LOOP, TWICE
// 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.

!Lock this in

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.

§ 7 · THREE WORKLOADS, ONE QUESTION

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.

Same request, three workloads
§7 · the experiment
the control is the one where a worker makes things measurably worse

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.

§ 8 · BEFORE YOU SPAWN ONE PER REQUEST

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.

What you pay per worker
§8 · the bill
the thread itself
microseconds
kernel
Genuinely cheap, as Part 5 said. This is never the part that hurts.
a V8 isolate and context
milliseconds
node
A fresh heap, a fresh set of built-ins, a fresh compilation cache. Nothing is shared with the parent isolate, so nothing can be reused from it.
your modules, loaded again
milliseconds to seconds
node
Every require in the worker entry point runs again in the new registry. A worker that pulls in a large framework pays for it every time you spawn one.
a baseline heap per worker
several MB, resident
hw
Multiply by the number of workers. This is why a worker-per-request server runs out of memory long before it runs out of CPU.
spawn a pool once; do not spawn per task
!Lock this in

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.

§ 9 · HOW THE TWO SIDES TALK

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.

What happens to a postMessage
§9 · where a message lands
postMessage(value)
on the sending side
main
The value is structured-cloned immediately, synchronously, before the call returns. A large object costs you time right here, on the sending thread.
the clone is queued on the port
sitting in memory
node
It waits whether or not the receiver is listening. A worker that never reads its port is a memory leak with no error message.
the receiver's loop delivers it
as a message event
node
Part 4 all over again: it is a callback, so it runs when THAT isolate's loop gets to it. A worker busy in a tight loop will not see your message until it returns.
the clone happens on the sender; the delivery happens on the receiver
§ 10 · ERRORS DO NOT PROPAGATE THE WAY YOU EXPECT

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.

JAVASCRIPTTHE FOUR EVENTS WORTH HANDLING
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 error

If 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.

!Lock this in

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.

§ 11 · THE MODELS WORTH CORRECTING

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.

Which of these are true?
§11 · mental models
sort before you check — a guess you have committed to is worth more than a correction you read
!Lock this in

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.

§ 12 · THE FOUR SENTENCES WORTH KEEPING

If you remember nothing else

A worker is a second Node inside your process. Everything else follows from that.

What to carry away
§12 · the four sentences
isolated by default, shared by exception
§0
main
The opposite of threads elsewhere. You opt into sharing with a transfer list or a SharedArrayBuffer, and with nothing else.
clone costs bytes; transfer costs nothing
§3 · §4
node
A copy scales with size; a move does not. If the boundary is your bottleneck, you are cloning something you could have transferred.
workers are for CPU, never for I/O
§7
kernel
I/O never blocked your thread in the first place. Parts 5, 8 and 9 spent three documents establishing that.
shared memory needs Atomics
§6
worker
The moment two threads can reach one address, every read-modify-write is a race unless the CPU is told to make it indivisible.
four sentences, one boundary

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?

Back to JIT-Atlas