JavaScript, from the host up

Library/JavaScript, from the host up

31 Aug 2026

You write JavaScript every day, and most of what you rely on isn't JavaScript. The event loop that orders your awaits, the fetch that reaches the network, the module system that resolves your imports, the timers, the DOM — none of them are in the language. Search the ECMAScript specification for "event loop" and you get zero hits. The spec defines values, objects, functions, and one small piece of asynchrony — a queue of microtasks; everything else is supplied by a host, Node or the browser, and executed underneath by one engine, V8, shared by both. That division is the idea this guide is built on.

It comes in three movements. First the language itself, taken from the ground up — the primitive-or-object distinction that everything downstream reads off, and how coercion, this, closures, prototypes, generators, promises, modules, metaprogramming, and memory actually work. Then the two runtimes: what Node adds — its phased event loop, its I/O, its modules, its streams, its threads — and how the browser answers the same questions differently, one loop fused to rendering. Then the engine beneath both, and what your code does that makes V8 fast or slow. Throughout, Python is a foil — the same problems solved by a different language — never a prerequisite.

JavaScript is a guest; the host runs the show

Say "JavaScript" and you mean the whole thing you write every day: setTimeout, fetch, document.querySelector, require, console.log, and the event loop that somehow finds the right moment to run your callbacks. Almost none of that is JavaScript.

The language is defined by one document, ECMA-262, the ECMAScript specification. It is about three million bytes long. Here is everything it has to say about the event loop, and about the timer you reach for a dozen times a day:

grepping the ECMA-262 source — the specification that *is* JavaScript
$ wc -c spec.html
3088170 spec.html
$ grep -c -i "event loop" spec.html
0
$ grep -c "setTimeout" spec.html
0

Zero, and zero. The event loop is not in the language. Neither is setTimeout, and neither are fetch, document, require, process, or console. You use them through JavaScript, but the specification that defines JavaScript has never heard of them. That single fact is the spine of this guide, so it is worth naming the three layers it splits the world into.

The language is ECMAScript, what ECMA-262 defines. It is smaller than you think: values and the operations on them, objects and their prototypes, functions and scope, and exactly one sliver of asynchrony (a queue of deferred jobs you will meet in chapter 9). Nothing about files, sockets, clocks, the screen, or how a module name becomes a file. This is Part I, and it is what your code can rely on in every place JavaScript runs.

The host is whatever embeds the language, Node or the browser. It supplies everything the specification leaves out: the event loop that drives your callbacks, timers, file and network I/O, the DOM, the rule that turns "./utils.js" into a path on disk, and any threads. Two different hosts, two different sets of answers, one guest that can't tell which house it's in without asking. This is Part II.

The engine is V8, the program that actually compiles and runs the language, on behalf of whichever host embedded it. V8 is the engine underneath both Node and the browser (in different versions, which Part III makes something of). This is Part III.

The title is meant literally. The language is a guest in someone else's house. The house provides the plumbing, the doors, the clock on the wall, and it decides when the guest is allowed to speak; the guest brings only the ability to compute, to hold values, make decisions, keep state. The analogy has one seam worth marking now: the engine is not part of the house. It is closer to the guest's own body, the thing that does the actual thinking.

One thread, one stack, one thing at a time

Underneath all three layers is a model so simple it is easy to miss: your code runs on one thread, doing one thing at a time. Not one thread by convention, but one thread by design, and every hard question about async later in this guide is a question about how a single thread fakes doing several things at once.

The thread's workspace is the call stack. Call a function and the engine pushes a frame onto it: a small record of that call's local variables and where to return when it finishes. You can see the stack directly, because an error carries a snapshot of it:

first calls second calls third, which throws — the printed stack, top frame first
Error: boom
    at third (repro/lang/[eval]:2:27)
    at second (repro/lang/[eval]:3:21)
    at first (repro/lang/[eval]:4:21)

third is running, called by second, called by first. Each return pops a frame off the top. When the last frame pops, the stack is empty and this piece of work is finished.

While a frame is on the stack, it runs to the end. Nothing interrupts a running function to slip another one in between two of its lines, a rule called run-to-completion. Two functions that push values to a shared list make it visible:

A0 A1 A2 B0 B1 B2

A ran all the way through before B began; the outputs never interleave. On a single thread that looks obvious. It stops looking obvious the moment a timer or a promise is involved, and it is exactly the guarantee that makes chapter 9's async ordering predictable instead of a race. The specification states it for the deferred jobs that async is built from:

Once evaluation of a Job starts, it must run to completion before evaluation of any other Job starts in an agent.

And it says when such a job is allowed to start, a line that quietly divides the language from the host:

At some future point in time, when there is no running context in the agent for which the job is scheduled and that agent's execution context stack is empty, the implementation must … Invoke the Job Abstract Closure.

The language decides that a job runs to completion once the stack is empty. It does not decide when the stack goes empty, or which job runs next, or how one gets scheduled in the first place. That is the loop, and the loop belongs to the host. You will build the whole of it in Part II. Part I, starting now, is the guest: everything true of your code whether it runs in Node or in a browser, because it is the language, not the house.

Seven primitives, and everything else is an object

You have heard that everything in JavaScript is an object, and you can almost prove it: "untitled".toUpperCase() and (5).toFixed(2) are method calls on a string and a number, so strings and numbers must be objects. The Python guide opened on exactly this claim and meant it. There, type(type) is type, and every value in Python, down to the integer 3, is a genuine object. JavaScript takes the opposite bet. It carves out a handful of values that are deliberately not objects, and the difference shows the moment you try to treat one as though it were.

Give a string a property and read it back:

a module — so, strict mode — writing a property onto a string
const slug = "untitled";
slug.revision = 2;
console.log(slug.revision);
TypeError: Cannot create property 'revision' on string 'untitled'

Not undefined, not 2, but a thrown error, on the assignment line. There is nowhere to put revision, because slug is not an object. It has no slots.

Strict mode, before anything else runs

That the code threw, rather than quietly doing nothing, is the second half of the lesson, and it is why this guide can show you anything at all. JavaScript has two modes. In strict mode, an operation the language considers a mistake — writing to a primitive, assigning to a read-only property, using an undeclared variable — raises a TypeError or ReferenceError. In sloppy mode, the historical default, the same operations fail silently. Run the identical three lines as an old-style script instead of a module:

the same code as CommonJS — sloppy mode
slug.revision is now: undefined

No error. The write was accepted, discarded, and the read came back undefined, a bug that leaves no mark. The rule that decides which you get is mechanical: an ES module (a .mjs file, or anything loaded with import) is always strict, and so is the body of a class; a .cjs file or a bare node -e script is sloppy. Every demo in this guide is a module. So every one runs strict, and the writes that matter throw where you can see them instead of vanishing. That is the whole reason to say it now, once: when a line here fails loudly, sloppy mode would have let it pass.

The seven, and the two lies of typeof

What threw was a write to a primitive, one of seven kinds of value the language treats as a bare thing rather than an object: undefined, null, boolean, number, string, symbol, and bigint. Everything else — {}, [], functions, dates, regexes, every instance of every class — is an object. The probe for which is which is typeof, and it is honest about the seven but lies twice:

typeof undefined  -> undefined      typeof "hi"       -> string
typeof null       -> object   (!)   typeof Symbol()   -> symbol
typeof true       -> boolean        typeof 42n        -> bigint
typeof 42         -> number         typeof (()=>0)    -> function  (!)

typeof null is "object", and null is not an object. It is its own primitive. This is a genuine wart, baked into the spec's own algorithm, and the only correct test for null is x === null. typeof (()=>0) is "function", and "function" is not one of the eight language types (the seven primitives plus object) either; a function is an object that happens to be callable, and typeof reports "function" as a convenience. Arrays lie the other way: typeof [] is "object", so to detect one you need Array.isArray(x).

How a primitive borrows methods it doesn't have

If slug has no slots, how did slug.toUpperCase() work back at the start? Through autoboxing: when you read a property on a primitive string, number, boolean, symbol, or bigint, the engine wraps it in a temporary wrapper object, a String or Number and so on, reads the property off that, and throws the wrapper away. The primitive itself never changes. That temporary is why the method call succeeds and the property write does not: the write lands on an object that is discarded a microsecond later, so in strict mode the language refuses it outright rather than pretend it stuck.

You can summon the wrapper yourself with new, and it is a different type, and a trap:

typeof "hi"               -> string
typeof new String("hi")   -> object
new Boolean(false) ? ...  -> TRUTHY

new Boolean(false) is an object, and every object is truthy, so it takes the wrong branch of an if while looking exactly like false. Never construct a wrapper on purpose. Let autoboxing do its silent work and keep your values primitive.

Primitive or object is the first question the language asks about any value, and nearly everything in the chapters ahead reads off the answer: how == coerces, what this binds to, what a WeakMap will accept as a key. Hold the split. It is the one Python doesn't have.

== is one algorithm, not a bag of exceptions

Here is the list everyone has seen, and the reason everyone is told to never use ==:

"" == 0             -> true
false == "0"        -> true
[] == false         -> true
null == undefined   -> true
null == 0           -> false

Read as trivia, it is a bag of unrelated quirks, and the safe response is to memorize none of it and always type ===. That works, and it leaves you unable to read the four lines above, or to explain why the last one flips to false when everything about it rhymes with the line before. So don't memorize the cases. Learn the one procedure they all come out of. Coercion, the language's implicit conversion of a value from one type to another, runs by rules rather than by whim, and == is the shortest place to watch it work.

The specification calls the operation IsLooselyEqual (§7.2.14), and it is seven steps. Rendered in plainer words, keeping only the ones that fire in day-to-day code:

the == algorithm, §7.2.14 in plain terms — each step retries with one side converted
1. Same type?          -> use ===
2. null vs undefined   -> true   (either order)
3. Number vs String    -> convert the String to a Number, retry
4. Boolean on a side   -> convert the Boolean to a Number, retry
5. primitive vs Object -> convert the Object to a primitive, retry
6. otherwise           -> false

Same types never coerce. They fall straight to === at step 1, so == and === agree except when the types differ. When they differ, the algorithm nudges both sides toward numbers, one conversion at a time, and retries. Every notorious case is that loop turning:

the same steps, run by hand on three of the opening lines
"" == 0        String vs Number  -> ToNumber("") is 0  -> 0 == 0     -> true
false == "0"   Boolean first     -> ToNumber(false) 0  -> 0 == "0"
               Number vs String  -> ToNumber("0") is 0 -> 0 == 0     -> true
[] == false    Boolean first     -> ToNumber(false) 0  -> [] == 0
               Object vs Number  -> ToPrimitive([]) "" -> "" == 0    -> true

false == "0" is not "false equals the string zero." It is "both sides become the number 0," reached by different roads. And [] == false is true because an empty array flattens to an empty string, which becomes 0, which the boolean also became. (ToPrimitive is the object-to-primitive conversion; chapter 11 shows how an object controls its own. For now: an array becomes its joined contents, so [] becomes "".)

Why null == 0 is false

Look at the steps again for anything that converts null to a number. There isn't one. null and undefined appear in exactly one place, step 2, matching only each other, and nowhere else. So:

null == undefined   -> true    (step 2, the only step they touch)
null == 0           -> false   (no step coerces null to a number)
undefined == 0      -> false   (same)

One line is worth more than the whole "wat" list: null and undefined are loosely equal to each other and to nothing else. That is not an exception to the algorithm; it is the absence of any step that would coerce them further. It is also what makes x == null a precise, deliberate idiom, true for exactly null and undefined, the two "no value" values, and false for 0, "", and false.

The eight that are falsy, and the bug that costs you zero

Coercion to boolean has its own short list. Exactly eight values are falsy — false, 0, -0, 0n, "", null, undefined, and NaN — and everything else is truthy, including [], {}, the string "false", and a single space " ". That list is the whole definition of what an if treats as false.

It is also a live bug, through the difference between || and ??. x || fallback takes the fallback when x is any of the eight falsy values; x ?? fallback takes it only when x is null or undefined. On a real value of 0 or "", they disagree:

data.count ?? "fallback"   -> 0            (0 is a value, kept)
data.count || "fallback"   -> "fallback"   (0 is falsy, eaten)

If count can legitimately be 0, a cart with zero items or a score of nothing, then count || 20 silently rewrites it to 20. ?? was added to the language for exactly this: fall back on absence, not on falsiness. Reach for || and you inherit all eight falsy values as triggers whether you meant them or not.

The two places === itself is wrong

Strict equality is not the ground truth either. It disagrees with mathematics twice, and Object.is is the version that doesn't:

NaN === NaN         -> false      Object.is(NaN, NaN) -> true
-0 === 0            -> true       Object.is(-0, 0)    -> false

NaN failing to equal itself is not a bug. It is the one value in the language for which x !== x is true, which is precisely how Number.isNaN is built. (The global isNaN coerces first, so isNaN("banana") is true; Number.isNaN("banana") is false and is the one you want.)

Mistake

NaN and Infinity have no representation in JSON, so a round-trip through JSON.stringify silently turns each into null:

JSON.stringify(NaN)       -> null
JSON.stringify(Infinity)  -> null
JSON.stringify([NaN, 1])  -> [null,1]

A field that left as NaN comes back as null, with no error in between to warn you.

Three equalities, then, each for a different job: Object.is for "the very same value," === for everyday comparison, and == for the deliberate x == null check, now that you can read what it does instead of fearing it.

Numbers are floats, strings are UTF-16

Two of the seven primitives carry a surprise that has nothing to do with JavaScript and everything to do with how computers store a number and a character. Start with the one that gets JavaScript unfairly blamed:

> 0.1 + 0.2
0.30000000000000004

The reflex is that the language is broken. It is not. Run the same addition in Python:

python3:  0.1 + 0.2   ->  0.30000000000000004
node:     0.1 + 0.2   ->  0.30000000000000004

Identical, to the last digit. A JavaScript number is an IEEE-754 double, the same 64-bit floating-point format C, Java, and Python use for their doubles, so 0.1 is inexact in every one of them and the sum comes out the same everywhere. The specification says which format outright:

The Number type has exactly … values, representing the double-precision floating point IEEE 754-2019 binary64 values …

0.1 has no exact representation in binary, the way 1/3 has none in decimal; the stored value is the nearest double, and the tiny error surfaces when you add. The fix for comparing is to allow a margin: Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON is true, where Number.EPSILON is the gap between 1 and the next representable double.

There is no integer type

That single format holds your integers too, and of a double's 64 bits only 53 carry precision (52 stored, one implied), so integers are exact only up to Number.MAX_SAFE_INTEGER, which is 2**53 - 1:

Number.MAX_SAFE_INTEGER   -> 9007199254740991
2**53 === 2**53 + 1       -> true
9007199254740993          -> 9007199254740992

Past that line the representable integers thin out, and x + 1 can equal x. A database id or a Twitter snowflake that exceeds 2**53 will silently round the moment it lands in a plain number. For exact integers of any size there is a separate primitive, BigInt, written with an n suffix:

2n ** 53n + 1n   -> 9007199254740993   (exact, no rounding)
typeof 10n       -> bigint
1n + 1           -> TypeError: Cannot mix BigInt and other types, use explicit conversions

BigInt and Number never mix in arithmetic; you convert one side explicitly. And one more value hides inside the number type: a negative zero. -0 === 0 is true, so it stays invisible until it doesn't. 1 / -0 is -Infinity, and Object.is(-0, 0) is false, which is how you catch a -0 that a Math.round or a subtraction quietly produced.

A string is 16-bit units, not characters

The other primitive with a leak is the string, and the mental model to unlearn is that a string's .length counts its characters. It counts something lower-level. The specification defines a string as a sequence of 16-bit numbers:

The String type is the set of all ordered sequences of zero or more 16-bit unsigned integer values … each element in the String is treated as a UTF-16 code unit value.

A code unit is one of those 16-bit slots. A code point is an actual Unicode character, which the encoding UTF-16 stores in one code unit for common characters and two, a surrogate pair, for anything above the Basic Multilingual Plane, which is where the emoji live. .length counts code units, so:

"😀".length            -> 2     (one emoji, stored as a surrogate pair)
"😀"[0]                -> "\ud83d"   (indexing gives you half a character)
"😀".charCodeAt(0)     -> 55357      (the numeric value of that half)
[..."😀"].length       -> 1         (spread walks code points)
[..."😀"][0]           -> "😀"       (…so you get the whole one)
"😀".codePointAt(0)    -> 128512     (the real character, U+1F600)

This is the split to carry: indexing and charCodeAt walk code units; for…of, spread, and [...str] walk code points. Reverse a string with .split("").reverse().join("") and every emoji in it shatters into broken halves; spread it into an array first and they survive. Skin-tone and flag sequences stack the problem, since a waving hand with a modifier is two code points and four code units:

"👋🏽".length         -> 4        [..."👋🏽"].length   -> 2

One last edge, because equal-looking text can differ underneath. The accented é can be one code point (composed) or two, a plain e plus a combining accent (decomposed):

"é"(composed) === "é"(decomposed)   -> false
lengths                             -> 1  vs  2
after .normalize("NFC") on both     -> true

They look the same and compare unequal, because they are different bytes. String.prototype.normalize folds them to one canonical form, and running it on both sides before comparing user-entered text is the difference between a search that finds a name and one that doesn't. Numbers pack everything into 64 bits and strings pack everything into 16-bit units; both are exact and fast, and both make you say what you mean the moment your data leaves the range where the shortcut held.

Objects are prototype chains, and class is sugar over them

Make one object inherit from another without a class in sight, and watch a lookup travel:

const animal = { eats: true };
const rabbit = Object.create(animal);   // rabbit's prototype is animal
rabbit.jumps = true;
rabbit.jumps                              -> true    (its own)
rabbit.eats                               -> true    (found on animal)
rabbit.hasOwnProperty("eats")             -> false   (not its own)
"eats" in rabbit                          -> true    (in walks the chain)
Object.getPrototypeOf(rabbit) === animal  -> true

rabbit has no eats of its own, yet reading rabbit.eats returns true. Every object carries a hidden link, its prototype, spelled [[Prototype]] in the spec, to another object, and reading a property that the object doesn't own follows that link, and the next, until the key turns up or the chain runs out:

the prototype chain a property read walks, top to bottom, until a match or null
rabbit ──▶ animal ──▶ Object.prototype ──▶ null
{jumps}    {eats}     {hasOwnProperty, toString, …}

That is JavaScript's whole inheritance model: not copying fields down into an instance, but delegating the lookup upward at read time. The chain ends at null, since Object.getPrototypeOf(Object.prototype) is null, which is why rabbit.nonsense is undefined rather than an error: the walk reached the top and found nothing.

The confusion around prototypes is really a confusion of three names. [[Prototype]] is the internal link itself, read with Object.getPrototypeOf. __proto__ is a legacy accessor for that same link, still around for compatibility. And .prototype is a different thing entirely: an ordinary property that only functions have, holding the object that will become the [[Prototype]] of every instance the function builds with new.

d = new Dog("Rex")
d.__proto__ === Dog.prototype             -> true
({}).prototype                            -> undefined   (plain objects have none)

So Dog.prototype is not Dog's own prototype. It is the prototype Dog hands out. Wire that together and you have described class completely.

class is that machinery, spelled shorter

A class looks like Java, so it invites you to believe JavaScript grew real classes. It didn't. A class is a function whose .prototype holds the shared methods:

typeof Cat                         -> function
c = new Cat()
c.meow === Cat.prototype.meow      -> true     (the method lives on the prototype)
c.hasOwnProperty("meow")           -> false    (…not on the instance)
Cat.species                        -> felis    (static: a property of the function itself)
Object.getPrototypeOf(Derived) === Base  -> true   (extends links the prototypes)

Every method you write in a class body lands on Cat.prototype, exactly where Object.create would have put it; extends sets one prototype's prototype to another; static members hang off the constructor function; super.greet() finds the next method up the chain. Nothing was added to the object model. class is a cleaner way to build the chain you just built by hand, which matters for the TypeScript guide, where the same class also carries types, and for reading any code that predates it.

A property is a value plus three flags

rabbit.jumps looks like a slot holding true, but every property is really a property descriptor: a value together with three flags — writable, enumerable, configurable — or, instead of a value, a getter and setter. The flags are invisible until they bite, and the first trap is that their defaults depend on how you made the property:

literal        { id: 1 }                     -> writable:true,  enumerable:true,  configurable:true
Object.defineProperty(o, "id", {value: 1})   -> writable:false, enumerable:false, configurable:false

Create a property with defineProperty and forget to pass the flags, and you get a read-only, hidden, locked property, the opposite of the literal's wide-open defaults. enumerable:false means Object.keys, for…in, and JSON.stringify all skip it; writable:false means a write throws in strict mode (TypeError: Cannot assign to read only property). Getters and setters are the other kind, an accessor descriptor carrying get/set instead of value, and running one looks like a plain field read while executing your code underneath. Class methods, incidentally, are created enumerable:false, which is why they don't show up in Object.keys(instance) the way a method assigned to a plain object literal would.

freeze stops at the surface

Object.freeze sounds total, and it is exactly one level deep:

config.port (frozen, write ignored/throws)   -> 8080
config.nested.debug = true                    -> succeeds!
Object.isFrozen(config)                       -> true
Object.isFrozen(config.nested)                -> false

Freezing config locks its own properties but does nothing to the objects they point at; the nested object is still wide open. A deep freeze is your own recursion over the tree. (And const freezes even less, only the binding, so const o = {} still allows o.x = 1. Chapter 6 is where that distinction lives.)

One last enforcement runs deeper than any flag. A private field, declared with #, is enforced by the engine at parse time; reach for one from outside its class and the program won't even compile:

c.#whiskers   ->  SyntaxError: Private field '#whiskers' must be declared in an enclosing class

That is a SyntaxError, not a runtime TypeError: the code is rejected before it runs, which no naming convention or WeakMap trick ever achieved. Underneath, though, #whiskers is still a property on an object whose lookup the engine has walled off.

Testing for a private field

Because reaching for #whiskers from outside throws at parse time, you can't probe for it with a try/catch. The brand check #whiskers in obj does the job without throwing, reporting whether an object carries this class's private field:

Cat.has(new Cat())   ->  true      // static has(obj) { return #whiskers in obj; }
Cat.has({})          ->  false

Objects the whole way down; class and # are two ways of drawing the walls more sharply.

A closure captures the variable, not the value

Build three functions in a loop, each meant to return the number it was born on. Predict the array before you read it:

const funcs = [];
for (var i = 0; i < 3; i++) funcs.push(() => i);
console.log(funcs.map(f => f()));
[3, 3, 3]

Not [0, 1, 2]. All three return 3, a number the loop had already left by the time any of them ran. If you have read the Python guide this is the same trap in a different costume, since there [lambda: i for i in range(3)] gives [2, 2, 2], and the wrong explanation is the same too: "each function remembers the i it was made with." It is exactly backwards. A function does not photograph the value of i when it is created. It keeps a live reference to the variable i and reads whatever is in there when it is finally called.

That live reference is a closure: a function bundled together with the environment it was defined in. The question is how many is that environment holds. var is function-scoped, one binding for the entire enclosing function no matter how many times the loop runs, so all three closures close over the same i, and by call time the loop has run it up to 3. Three closures, one variable, one final value.

The fix JavaScript has and Python doesn't

Change one keyword:

for (let j = 0; j < 3; j++) funcs.push(() => j);
[0, 1, 2]

let is block-scoped, and in a loop it does something stronger than scoping: it mints a fresh binding every iteration. This is not a convenience the engine improvises. The specification spells out the step, CreatePerIterationEnvironment, run once per turn of the loop:

Let thisIterationEnv be NewDeclarativeEnvironment(outer). For each element name … Perform thisIterationEnv.CreateMutableBinding(name, false). Let lastValue be lastIterationEnv.GetBindingValue(name, true). Perform thisIterationEnv.InitializeBinding(name, lastValue).

A brand-new environment each time round, seeded with the previous iteration's value. So each closure closes over a different j, frozen at the moment that iteration ended. Here is the exact fork with the Python guide worth holding: both languages have this bug for the same reason, and only JavaScript fixed it in the language. Python's loop variable stays shared, so the repair is per-closure, the lambda i=i default-argument trick that copies the value out by hand. JavaScript changed what the loop variable is. Same mechanism on both sides (a closure captures the variable, not the value); opposite resolutions.

Why var behaves that way: hoisting and the dead zone

The scoping difference rests on a lower mechanism. A var declaration is hoisted, lifted to the top of its function and initialized to undefined before any line runs, so reading it above its own declaration is legal and gives undefined rather than an error. let and const are hoisted too, but deliberately left uninitialized until their declaration executes, a gap called the Temporal Dead Zone. Touch the name inside that gap and it throws:

reading a let before its line     -> ReferenceError: Cannot access 'y' before initialization
reading a never-declared name     -> ReferenceError: undeclaredName is not defined

Those are two different errors for two different situations. The dead zone means "declared, not yet alive," while the other means "no such name at all," and telling them apart is half of debugging a ReferenceError. One myth to kill here: typeof does not make the dead zone safe. typeof z, where z is a let further down the same scope, throws the same "before initialization" error; typeof only swallows the error for a name that was never declared anywhere. The dead zone is why hoisting stopped being a footgun when let arrived: a use-before-declaration that var would have let slide as undefined now fails loudly.

const adds one rule on top of let: it forbids reassignment, not mutation. const total = 1; total = 2 throws TypeError: Assignment to constant variable, but const cart = []; cart.push(item) is fine. The binding is fixed, the object it points at is not.

The same machinery that made the loop bug is also the language's most useful tool. A function that returns functions gives each of them a private, persistent variable:

makeCounter(): increment twice, then read   -> 2
a second counter, independent               -> 0

Each call to makeCounter creates a new environment with its own count, and the returned closure keeps that environment alive for as long as you hold the function. Two counters never see each other's state. That "keeps the environment alive" is not only a scoping fact; it is a memory fact, the reachability edge chapter 12 is built on. Capture the variable, not the value: a bug in a loop, a feature everywhere else.

this is decided at the call site

A method that works, and the same method one line later throwing:

const timer = { secs: 0, tick() { return ++this.secs; } };
timer.tick()               -> 1
const t = timer.tick; t()  -> TypeError: Cannot read properties of undefined (reading 'secs')

Nothing about tick changed. It is the identical function object both times. What changed is this, and that is the whole lesson: this is not a property of the function, fixed where the function was written. It is an argument the function receives fresh on every call, chosen by the call site, the expression that does the calling. timer.tick() and t() are two different call sites, so they hand tick two different values of this, and the second one is nothing.

Coming from Python this is the surprise, because Python makes the same idea explicit and early. There, self is an ordinary first parameter, and timer.tick is already bound to timer the moment you write the dot, so detaching it changes nothing. JavaScript leaves this implicit and decides it late, at the call, which is why the same function can mean different objects depending only on how you spell the call.

There are four ways a call site can set this, and it is worth having them ranked, because when two apply the higher one wins.

Default, a plain call, f(). No object, no context. In strict mode, which every module is, this is undefined:

whoAmI()                                    -> undefined

This is the timer bug. t() is a plain call, so this is undefined, and undefined.secs throws. (In sloppy mode this would be the global object instead of undefined, the single biggest reason a snippet behaves one way pasted into a module and another way in a REPL.)

Implicit, a method call, obj.f(). this is the object left of the dot, chosen at the call, not at the definition:

desk.whoAmI().label                         -> desk
shelf.whoAmI().label                        -> shelf     (same function, other receiver)

One function, assigned to two objects, returns whichever object called it. Pull it off the object with const t = timer.tick and there is no dot left to name a receiver, so you fall back to the default rule, and back to the bug.

Explicit, call / apply / bind. You supply this yourself. call and apply set it for one invocation; bind returns a new function welded to that this for good:

whoAmI.call(desk).label                     -> desk
bound = whoAmI.bind(desk); bound.call(shelf).label  -> desk   (bind won; call couldn't move it)

new, construction. new f() builds a fresh object and binds this to it, and new outranks even bind:

new Point(5).x                              -> 5
new (Point.bind({x: 99}))(5).x              -> 5     (new overrides the bound this)

Arrows have no this, which is the point

An arrow function is the exception that proves the rule: it has no this of its own at all. It closes over the this of the scope it was written in, a lexical this captured the way any closure captures a variable, which is exactly why this chapter comes after closures rather than before. Because the binding is lexical, no call site can move it; call and bind are powerless against an arrow.

That immovability is the fix for the opening bug. Write tick as a class-field arrow and it captures the instance once, at construction, and survives being torn off the object:

class Timer { secs = 0; tick = () => ++this.secs; }
const detached = timer.tick; detached()     -> 1

No receiver, no dot, and it still works, because the arrow already closed over this and stopped listening to the call site. This is why you reach for an arrow the instant a method becomes a callback: an event handler, a setTimeout, a .map. The callback is invoked with a plain call by machinery that has never heard of your object, so a normal method loses this and an arrow keeps it.

The procedure is short, and it never involves looking at where the function was defined. To read this, read the call. Is it new f()? Then this is the new object. Is it f.call(x) or a bind? Then this is x. Is it obj.f()? Then this is obj. Is it a bare f()? Then this is undefined, and if you meant something else, an arrow or a bind is how you say so.

Iteration is a protocol, and a generator is a function you can pause

for…of is not a built-in that knows about arrays. It is sugar over a small contract any object can satisfy, and the way to see the contract is to drive it by hand. Every array carries a method under a special key, Symbol.iterator, that hands back an iterator, and an iterator is anything with a next():

const colors = ["red", "green", "blue"];
const it = colors[Symbol.iterator]();
it.next()   -> { value: "red",  done: false }
it.next()   -> { value: "green", done: false }
it.next()   -> { value: "blue",  done: false }
it.next()   -> { done: true }

That is the entire iteration protocol. An iterable is any object with a [Symbol.iterator]() method; it returns an iterator, an object whose next() yields { value, done } until done goes true. for…of, spread ([...x]), and array destructuring are all callers of this protocol. If you read the Python guide, this is the same machinery you met there, since Python's __iter__ returns an object with __next__, with one deliberate difference: Python signals the end by raising StopIteration, while JavaScript sets a done flag on an ordinary return. A flag, not an exception, for the same stop.

(Symbol.iterator is your first well-known symbol: a unique, forgery-proof key the language reserved so this method can't clash with a property you happen to name "iterator". Chapter 11 opens the rest of them; here you need only that it is the agreed-on hook.)

Because it is a protocol and nothing more, you can implement it. Give a plain object a [Symbol.iterator] and it works everywhere an array does:

[...range]                    -> [1, 2, 3]
for…of over the range object  -> 1, 2, 3

A generator writes the iterator for you

Hand-writing next and tracking state is tedious, and a generator removes all of it. A function written function* with a yield in its body doesn't run when you call it. It hands back a generator object, which is an iterator (and, conveniently, its own iterable). Nothing in the body executes until you pull the first value:

typeof watch("cpu")                    -> object   (called it; body has NOT run)
g[Symbol.iterator]() === g             -> true     (it is its own iterator)
g.next()  ->  [body of watch(cpu) running]  { value: "cpu:1", done: false }

The first next() runs the body up to the first yield, hands out the yielded value, and freezes the function exactly there, every local variable intact, until the next next() resumes it. This is precisely the Python generator, down to the behaviour that calling the function runs none of the body.

yield is a two-way street. The value goes out to the caller; the caller's next next(v) comes back in as the result of the yield expression. A running total driven entirely from outside:

rt.next()      -> { value: 0,  done: false }   (run to the first yield)
rt.next(10)    -> { value: 10, done: false }   (10 arrives as the yield's result)
rt.next(5)     -> { value: 15, done: false }

That is Python's .send, spelled next(v). The channel runs the other way too. g.throw(err) injects an exception at the paused yield, as if that line had thrown, so the generator's own try/catch can catch it and resume:

function* guarded() {
  try { yield "working"; }
  catch (e) { yield "recovered from: " + e.message; }
  finally { console.log("finally ran"); }
}
g.next()                          -> { value: "working", done: false }
g.throw(new Error("bad input"))   -> { value: "recovered from: bad input", done: false }
g.next()                          -> finally ran   { done: true }

The error arrived inside the paused function, was caught there, and turned into a recovery value; a later return, or falling off the end, still runs the finally. You cannot throw into a list, and that is the difference in kind: a generator is a live, suspended computation you can steer, not a sequence sitting in memory. g.return(v) is the gentler steering, forcing it to finish while still running any finally, and yield* delegates to another iterable, splicing its values into the stream:

[...outer()]                -> [1, "a", "b", 10, 20, 2]

One property catches people, and it is the same one that catches them in Python: a generator is one-shot. It is its own iterator, so iterating it twice spends it the first time and yields nothing the second:

first  [...spend]   -> [1, 2, 3]
second [...spend]   -> []

A generator is not a lazy list you can re-read; it is a single cursor over a computation, and once walked to the end it is spent. That single-pass nature is also what makes it cheap: an infinite generator of the natural numbers is fine to define and safe to consume, as long as you stop pulling, so take(naturals(), 5) returns [1, 2, 3, 4, 5] and never runs past what you asked for.

Hold onto the shape of a generator, a function that suspends at yield and resumes where it left off with all its state, because the next chapter is that shape with one part swapped out. Replace "the caller decides when to resume" with "the host's loop decides," and yield becomes await.

A promise is eager; await is yield with a loop behind it

Four lines. Predict the order they print:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
1
4
3
2

4 beating 3 is no surprise; the two console.logs are plainly synchronous. But 3 beats 2, and both of those were "asynchronous," both scheduled with a zero delay in spirit. So "async" is not one waiting room. There are two, they empty in a fixed order, and telling them apart is where JavaScript concurrency starts. Take the promise first.

The executor runs now; the .then runs later

A promise is an object standing in for a value that isn't ready yet. You build one with an executor, the function you pass to new Promise, and the first thing to know is that the executor runs immediately, on the spot, synchronously:

executor body (synchronous)     <- prints first
after construction

This is the eager half of the chapter title, and it is the clean fork with Python: a Python coroutine from async def is lazy, running nothing until something awaits it, whereas a JavaScript promise has already started. What is not synchronous is the callback you register with .then. A promise sits in one of three states, pending and then either fulfilled or rejected, and when it settles, your .then callback does not run right then. It is placed on the microtask queue and made to wait until the call stack is completely empty. That is why 3 printed after 4: the .then callback couldn't run until the synchronous lines 1 and 4 had finished.

Here is the trace of all four lines, sorted into the lanes they actually run in:

the 1,4,3,2 order — synchronous now, microtasks next, the host's tasks last
console.log('1')                        run now, on the stack        -> 1
setTimeout(() => log('2'), 0)           hand '2' to the host's timer queue
Promise.resolve().then(() => log('3'))  queue '3' as a microtask
console.log('4')                        run now, on the stack        -> 4

  stack now empty ─┬─ drain ALL microtasks first ──────────▶ 3
                   └─ then take one host task ─────────────▶ 2

What the language guarantees, and where the host takes over

Chapter 1 gave you run-to-completion from the specification. The same section defines these deferred callbacks as jobs, and guarantees two things about them: a job runs only "when there is no running context … and that agent's execution context stack is empty," and once it starts it "must run to completion before evaluation of any other Job starts." So the language owns this much: all synchronous code runs first, then queued microtasks run, each to the end. That alone fixes 3 after 1 and 4.

It does not, by itself, fix 2 last. setTimeout is not in the language at all; you grepped for it in chapter 1 and found nothing. It is a host API, and its callback lands not on the microtask queue but on a separate, lower-priority task queue the host manages. The rule that every microtask drains before the host reaches for the next such task is also the host's, written in the browser's HTML standard and mirrored by Node, not bare ECMAScript. You can lean on it anyway, because every JavaScript host implements it, and you can watch it hold: five microtasks that each queue another all run before a setTimeout(0) that was scheduled first.

sync done
  microtask 1 … microtask 5
TIMER ran after 6 microtasks

What belongs to Part II is the loop itself, the thing that empties the stack, drains the microtasks, then decides which host task runs next and in what phase. The concept is here; the machine is chapters 13 and 14.

Chaining, and the four combinators

Each of .then, .catch, and .finally returns a new promise, which is what lets them chain: a thrown error skips ahead to the next .catch, and a promise returned from a handler is adopted, flattened into the chain rather than nested. When you need several promises at once, four combinators cover the cases:

Promise.all([…])         -> array of results; REJECTS on the first rejection
Promise.allSettled([…])  -> never rejects; one {status, value|reason} per input
Promise.race([…])        -> first to SETTLE wins, resolve or reject alike
Promise.any([…])         -> first to FULFILL; if all reject -> AggregateError

await is the generator, with the loop resuming it

An async function always returns a promise. Even one with no await and a plain return 42 hands back a promise that fulfills to 42. Inside, await suspends the function exactly the way a generator's yield suspends it, and schedules its resumption as a microtask:

E: before await        (runs synchronously, up to the await)
F: caller continues    (the async fn returned; the rest of the caller runs)
G: after await         (resumed later, as a microtask)

That is the generator of chapter 8 with one part swapped. A generator hands control back to whoever calls next; an async function hands control back to the host's loop, which calls the equivalent of next when the awaited promise settles. "await is yield with a loop behind it" is Python's chapter title, and it transfers whole, with one honest difference. Python's loop is a library you start by hand with asyncio.run, while JavaScript's is the host's and always already running. There is no run() to call, which is why fire-and-forget is normal here and a warning in Python.

The myth to retire on the way out: async is not another thread. It is one thread taking turns, and await is where a turn ends. A tight CPU loop inside an async function never awaits, so it never yields the thread, and everything else waits behind it: every timer, every other promise. Concurrency here is interleaved waiting, not parallel work. Who does the interleaving is the host's loop, the subject of Part II — though three more chapters finish the language first: modules, metaprogramming, and memory.

An import is a live binding, not a copy

import { count } from './counter.js' reads like const count = something, so it looks like a copy: you took the value count had and now you hold your own. For an ES module that is wrong, and the difference is visible the moment the exporter changes its variable out from under you. Here are two counters, one in each module system, with identical logic — a count, and an increment that bumps it:

// counter.mjs  (ES module)          |   // counter.cjs  (CommonJS)
export let count = 0;                //   let count = 0;
export function increment() {        //   function increment() { count++; }
  count++;                           //   module.exports = {
}                                    //     count, increment,
                                     //     get liveCount() { return count; },
                                     //   };

Both callers import the counter, call increment() twice, then read count back. The ES module:

[counter.mjs body evaluated]        <- ran BEFORE main's first line
main.mjs body starts
imported count = 0
imported count = 2                  <- after two increment() calls: LIVE
assigning to an import -> TypeError: Assignment to constant variable.

And CommonJS, the identical sequence:

main.cjs body starts
[counter.cjs body evaluated]        <- ran AT the require() call, inline
c.count = 0
c.count = 0                         <- after two increments: STILL 0, a COPY
c.liveCount = 2                     <- a getter re-reading the live variable
c.count = 42                        <- exports is a plain object; reassign at will

The imported count tracked the exporter's variable to 2. The required c.count stayed frozen at 0, because it was the value count held at the instant the exports object was assembled. Only the getter, which re-runs return count on every read, caught up.

The mechanism, from the spec

An ESM import is not a variable holding a copied value. The specification calls it an immutable, indirect binding:

Module Environment Records also provide immutable import bindings which are bindings that provide indirect access to a target binding that exists in another Environment Record.

Read count in the importer and the lookup reaches through to the exporter's own count and returns whatever is there now — a live binding. "Immutable" is why count = 99 at the import site throws: the alias is read-only, so only the module that owns the variable may change it, and every importer sees the change. CommonJS has none of this machinery. module.exports is an ordinary object built at run time, and require hands it back; destructuring const { count } = require(...) copies the primitive exactly as const a = obj.count would. The getter works only because a getter is code, re-executed per read, not a stored value.

Static structure is the other half

The two logs about when each body ran are the second difference. counter.mjs evaluated before main.mjs's first line; counter.cjs evaluated at the require call, in place. ESM imports are static — their names and sources are fixed before any code runs, so the engine reads the whole dependency graph, evaluates it in order, and wires the live bindings up front. That is what lets an import hoist to the top regardless of where you write it, and what lets a bundler see, without running your code, which exports are never used and drop them. CommonJS require is a plain function call that returns a value, so it runs where it sits and can go anywhere — inside an if, inside a function — at the cost of being invisible to any tool that hasn't executed the program.

The language also gives a module one host-populated object, import.meta. Here import.meta.url is a file:// URL string pointing at the current module. What fills it, though, is not the language's business — and neither is the larger question this whole chapter has been leaning on: how does './counter.js' become an actual file? The walk up through node_modules, the package.json "exports" and "type" fields, the extension guessing, the rules for a CommonJS file importing an ES one — module resolution is entirely the host's, and in Node it is chapter 16. The language owns four things here: the import/export syntax, the live-binding semantics, the static structure, and import.meta as an object. The host owns where the file is and what goes in the object. Same seam as always — the guest describes the shape of the door; the house decides what's behind it.

Symbols, proxies, and the operations you can intercept

An object that answers to arithmetic, string interpolation, and instanceof as if it were a built-in type:

+temperature                          -> 20
`${temperature}`                      -> 20°C
Object.prototype.toString.call(temperature)  -> [object Temperature]
[...temperature]                      -> [20, 68]
4 instanceof Even                     -> true     (Even has no instances and no subclasses)

None of +, `${}`, toString, spread, or instanceof is hard-wired to the built-in types. Each is a dispatch point: the operator looks for a method stored under a specific key, and if your object has that method, the operator runs it. The keys are well-known symbols, unique primitive values the language reserves and publishes, so its own operations have a name to call that no ordinary property can accidentally shadow. You met one already, Symbol.iterator, which is what spread and for…of look up. The rest of the family hooks the rest of the operators:

  • Symbol.toPrimitive is what coercion calls, the ToPrimitive step from chapter 3, so it drives +temperature and `${temperature}`.
  • Symbol.hasInstance is what instanceof calls, which is how 4 instanceof Even returns true with no inheritance anywhere in sight.
  • Symbol.toStringTag is the label Object.prototype.toString reads, turning [object Object] into [object Temperature].

Because a symbol key is not a string, it stays out of the way of ordinary iteration: Object.keys, for…in, and JSON.stringify all skip symbol-keyed properties, and only Object.getOwnPropertySymbols or Reflect.ownKeys reveal them.

A Proxy intercepts the operations wholesale

A well-known symbol hooks one operation. A Proxy wraps an object and intercepts every fundamental operation on it — reading a property, writing one, checking in, deleting — routing each through a handler you supply. A handler that logs and forwards records what the language did to the object:

trap log                    -> ["get a", "set b=5", "has a"]
writes passed to the target -> 5

The forwarding is done with Reflect, which is the same set of operations as plain functions: Reflect.get(target, key) is exactly what target[key] would have done. It is the default behaviour, packaged so a proxy trap can call it after doing its own work. Reflect methods also report success as a return value instead of throwing, so Reflect.set(...) returns true or false, which is often what you want inside a trap.

A proxy is powerful but not omnipotent, and the limit is worth seeing, because it tells you the object model's guarantees are real rather than advisory. A property that is non-configurable and non-writable is a promise the language made about that object, and a get trap is not allowed to break the promise by returning a different value:

TypeError: 'get' on proxy: property 'pinned' is a read-only and non-configurable data
property on the proxy target but the proxy did not return its actual value
(expected '42' but got 'a different value')

The engine checks the trap's answer against the target's real property and throws when the trap lies. These are the invariants, the rules a proxy must respect no matter what its handler says, and they are why code that receives a frozen object can trust it stays frozen even when what it received turns out to be a proxy.

The smaller hooks

Two smaller tools round the chapter out. A tagged template lets a function intercept string interpolation: tag`hi ${name} = ${n}` calls tag(strings, ...values), handing your function the literal chunks as an array and the interpolated values separately, so you can escape, sanitize, or rewrite them before they become a string — how a SQL or shell tagger quotes exactly what you typed.

The other is for reaching into a shape that might not be there. ?. short-circuits the whole remaining chain the instant it meets null or undefined, and it covers calls too, so api.get?.() returns undefined rather than throwing when get isn't a function. Pair it with the ?? from chapter 3, falling back on absence rather than falsiness, and you can walk an uncertain object graph without a stack of && guards or a swallowed 0.

Memory is reachability, and Weak* is for caches you can lose

There is no free in JavaScript, and delete doesn't do it: delete obj.key removes a property, not the object it pointed at. You never hand memory back. So what decides when it is reclaimed? One rule, and the whole of memory management follows from it: reachability. A value stays alive as long as the program can still reach it by following references from a root, meaning a global or a variable on the current call stack. When no chain of references leads to a value any more, it is garbage, and the collector is free to take it. Liveness is not about whether you're "done" with a value; it's about whether anything can still get to it.

Closures are where this turns concrete, because a closure is a reference edge. From chapter 6, a function keeps its captured environment alive:

closure still reaches the captured array   -> 1000000

A function that closed over a million-element array holds that array reachable for exactly as long as you hold the function; drop the function with holder = null and the edge is cut and the array becomes collectible. This is the mechanism behind the classic leak: a forgotten event listener or cached callback that quietly pins a large object graph, not because you meant to keep it, but because a reference to it is still reachable through the closure.

References that don't pin

Sometimes you need to associate data with an object without being the reason it stays alive. That is the entire purpose of the four Weak types.

A WeakMap holds its keys weakly: an entry disappears on its own once its key is unreachable everywhere else. The trade-offs all follow from that. Keys must be objects, since a primitive can't be weakly held, so weak.set("id", …) throws TypeError: Invalid value used as weak map key. And the map is not enumerable at all:

weak.get(key)              -> metadata
weak.size                  -> undefined     (no size, no keys(), no iteration)
weak[Symbol.iterator]      -> undefined

You cannot list a WeakMap, because doing so would expose whether a key has been collected yet, which is exactly the non-determinism it exists to hide. It is the right tool for metadata keyed by an object you don't own, such as private state or a "have I already processed this DOM node" mark, with no risk of the map itself being the leak. A WeakSet is the same idea for membership: tag objects as seen without keeping them alive.

A WeakRef goes further and weakly holds a single value you reach through deref(), which returns the target or undefined once it's been collected. The spec is precise about what it is:

A WeakRef is an object that is used to refer to a target object or symbol without preserving it from garbage collection.

That makes it right for a cache that may evict under memory pressure and rebuild what it lost. And a FinalizationRegistry registers a callback to run after an object is collected. Force a collection with Node's --expose-gc and it fires:

  global.gc() is available — forcing a collection...
  [finalizer ran for: temp-object]

But read what the specification actually promises, because the word is doing all the work:

When an object or symbol which is registered with a FinalizationRegistry becomes unreachable, a call of the FinalizationRegistry's cleanup callback may eventually be made, after synchronous ECMAScript execution completes.

May. Not will. A finalizer might run late, or never: if the program exits first, if the collector never gets around to that object. One caveat governs the whole area, and it is the one to carry out: Weak types and finalizers are for optimizations you can afford to lose, never for logic that must run. Releasing a lock, closing a file, flushing a buffer: put none of them in a finalizer, because "may eventually" is not a guarantee you can build correctness on. Use try/finally or an explicit close() for anything that must happen.

Why the language will only say "may" is the seam into Part III. ECMA-262 does not mandate a garbage collector at all. It describes memory as a reachability graph and leaves the sweeping unspecified on purpose. How the collector actually works — that it's generational, that it moves objects, that its pauses land on the very thread your event loop runs on — is the engine's, V8's, and it is where the last two chapters go. The guest gives you the graph. The engine decides when to walk it.

The event loop is the host's, and Node's has phases

From chapter 9 you have the rule the language guarantees: when the call stack empties, every queued promise callback — every microtask — runs to completion before anything else gets a turn. That is what made Promise.resolve().then(...) beat setTimeout(fn, 0) in 1, 4, 3, 2. It holds in every host.

What chapter 9 handed off is the loop that empties the stack in the first place. That loop is the host's, and Node's is more elaborate than "one queue for microtasks, one for everything else." Node has two schedulers the browser never had, process.nextTick and setImmediate, and a loop built in named phases. So take the four-way race you know, add the two Node-only ones, and predict the order:

node · five things scheduled from one synchronous run — predict the order
console.log('1 sync start');
setTimeout(() => console.log('setTimeout(0)'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('promise.then'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('process.nextTick'));
console.log('2 sync end');

Run it as an ES module (.mjs):

1 sync start
2 sync end
promise.then
queueMicrotask
process.nextTick
setTimeout(0)
setImmediate

The two synchronous lines first, then the two microtasks you know. Then process.nextTick, which is the surprise: every article about nextTick says it jumps ahead of everything, promises included, and here it ran after them. (The last two are racing; they get their own section below.) To see why, you need what Node does between your callbacks — the mental model of "what the host does when you call in" that the rest of Node rests on.

What these chapters were run against

Every number in Parts II and III comes from a real run on one machine: Node 22.22.2 (the 22.x "Jod" LTS), V8 12.4, libuv 1.51.0, and Chromium 141 (V8 14.1) driven headless. Two of those facts bite later: the two hosts run different V8 versions, and a few features are version-gated. Each is flagged where it matters. The timings are from this hardware (four cores); yours will differ in the digits, not the shape.

The wheel: libuv's phases

Node embeds a C library, libuv, to run its loop and talk to the operating system. libuv's loop is not one queue. It is a fixed cycle of phases, each with its own queue, visited in the same order forever:

one turn of the loop — each phase drains its own kind of callback, then hands to the next
        ┌─────────────────────────────────────────────┐
        │                                             │
        ▼                                             │
   ┌─────────┐   ┌──────────┐   ┌──────┐   ┌───────┐   ┌───────┐
   │ timers  │──▶│ pending  │──▶│ poll │──▶│ check │──▶│ close │──┐
   └─────────┘   └──────────┘   └──────┘   └───────┘   └───────┘  │
    setTimeout    deferred I/O   waits      setImmediate  'close'  │
    setInterval   callbacks      for I/O    callbacks     handlers │
    callbacks                    here                              │
        ▲                                                          │
        └──────────────────────── next turn ───────────────────────┘

You will use three of the five. timers runs setTimeout/setInterval callbacks that are due. poll is where the loop actually waits, asking the OS "any I/O ready?" and running the callbacks for whatever is. check runs setImmediate callbacks. The other two are plumbing: pending runs a few I/O callbacks held over from last turn, close runs 'close' handlers. The order is the content: setImmediate is a callback parked in check, which is always the phase right after poll. So setImmediate is not "immediate" and setTimeout(fn, 0) is not "zero." Each requests a run in a specific phase on some future turn.

The two queues drained between everything

Now Node's two extra queues, and the rule over all of them. Node keeps a next-tick queue (process.nextTick), and above it V8 holds the microtask queue (promise callbacks and queueMicrotask). Neither is a phase. They sit outside the wheel, and Node drains them between every callback and between every phase, at every seam:

Within Node.js, every time the "next tick queue" is drained, the microtask queue is drained immediately after.

So at any seam: run one callback, empty next-tick completely, empty microtasks completely, move on. Both drain to exhaustion — schedule more of their own kind while draining, and those run too before the loop advances. That is the "nextTick before microtasks" rule the blogs quote, and at a seam it is correct. But it assumes Node is at a seam, having just finished a callback. At the top of an ES module, Node is not.

Why an ES module puts nextTick last

Run the identical file as CommonJS (.cjs):

node · the same body, run as CommonJS
process.nextTick
promise.then
queueMicrotask
setImmediate
setTimeout(0)

Now process.nextTick is first, the classic order. Same lines, opposite result, and the only change is .mjs versus .cjs. The difference is where the module body runs. A CommonJS body is an ordinary synchronous function call from Node's C++ (chapter 16 shows the wrapper that makes it one); when it returns, Node is back in control at a seam, and drains next-tick first.

An ES module body is not a plain call. To support top-level await, the loader evaluates each body as a promise job, a microtask. So while your ESM top-level code runs, V8 is already inside a microtask checkpoint, draining the microtask queue. A .then you schedule joins the drain in progress and runs next. A process.nextTick, which fires only when control returns to Node, waits until V8 finishes the checkpoint and hands back, so it runs last. The docs say exactly this:

since ESM modules are processed already as part of the microtask queue, there queueMicrotask() callbacks are always executed before process.nextTick() ones since Node.js is already in the process of draining the microtask queue.

Watch the mechanism directly. Take the CommonJS probe, which normally runs nextTick first, and move it inside a Promise.resolve().then(...):

node · CJS at top level vs. the same probe inside a promise callback
--- CJS top level (sync) ---
  process.nextTick        ◀ nextTick first, the classic order
  promise.then
  queueMicrotask
--- CJS inside promise.then (microtask) ---
  promise.then            ◀ nextTick moves to LAST
  queueMicrotask
  process.nextTick

The probe inside a microtask reproduces the ESM order exactly, in a CommonJS file. It was never about file types. It is about whether your code runs at a seam (Node in control, nextTick wins) or inside the checkpoint (V8 in control, promises win). An ESM top level is the second case; everywhere else you write — inside any timer, I/O callback, or setImmediate — you are at a seam, and there nextTick beats the promises in both module systems.

The rule that survives

Don't memorise "ESM flips nextTick." Memorise the seam: process.nextTick runs when control is back in Node; promise microtasks run when control is in V8. An ES module body executes while V8 is mid-checkpoint, so there, and only there, the promises are already draining and nextTick waits its turn.

setTimeout(0) vs setImmediate: a race at the top, settled in I/O

Back to the two we set aside. From one synchronous run, setTimeout(fn, 0) and setImmediate genuinely race, and the winner depends on the machine. setTimeout lands in timers, the first phase of the next turn, but a timer's delay is floored near 1 ms, so whether it is "due" depends on whether that millisecond has passed. On a fast machine, startup beats the floor and setImmediate (in check) goes first; cross the 1 ms line and the timer wins. Force it with a busy-wait before the loop starts:

node · the same two calls, with a 0 ms and a 5 ms spin before the loop turns
$ node race.mjs 0     →  setImmediate   setTimeout(0)
$ node race.mjs 5     →  setTimeout(0)  setImmediate

Never paste a single top-level run and call it "the order." But inside an I/O callback it is deterministic, ten runs out of ten:

node · both scheduled from inside a file-read callback
setImmediate
setTimeout(0)

The wheel is why. An I/O callback runs in poll; the very next phase is check, where setImmediate lives, so it runs this same turn, while the timer waits a full lap for timers. The reliable rule is not about the top level at all: scheduled from within I/O, setImmediate always beats setTimeout(0).

One thread, and two ways to jam it

The wheel turns on one thread — the thread running your JavaScript. Two failure modes follow. The first is starvation: a callback that keeps rescheduling itself on the next-tick or microtask queue. Because Node drains those to empty before the loop may advance a phase, a self-refilling queue never lets it move. Arm a 50 ms timer, then recursively schedule process.nextTick for 400 ms:

node · a 50 ms timer behind a self-scheduling queue
[none]      50ms timer FIRED at 50ms
[nexttick]  stopped self-scheduling at 400ms, 2709845 recursions
            50ms timer FIRED at 407ms      ◀ starved 357ms
[microtask] stopped self-scheduling at 400ms, 2158645 recursions
            50ms timer FIRED at 407ms      ◀ identical

Due at 50 ms, fired at 407, because the loop could not reach timers until the queue stopped refilling. Folklore blames nextTick; queueMicrotask starves it identically. The second failure is blunter — blocking — no queue trick, just a synchronous stretch of CPU:

node · a 25 ms heartbeat, then a 400 ms synchronous burn
  heartbeat 1 @25ms
  heartbeat 2 @51ms
>> starting 400ms SYNC burn @60ms (loop is now blocked)
>> burn done @460ms
  heartbeat 3 @460ms      ◀ ~16 beats missed; the interval does NOT catch up
  heartbeat 4 @485ms

Nothing fires for the whole burn, because the one thread is busy and the loop cannot turn. The interval does not catch up its sixteen missed beats; it fires once, late, and resumes. This is why chapter 15 exists (get the waiting off this thread) and chapter 19 exists (get the CPU work off it).

So the model, whole: your JavaScript runs on one thread, and between every callback Node drains next-tick then microtasks to empty, then advances a wheel of phases — timers, poll for I/O, check for setImmediate — waiting for the OS only in poll. The surprising nextTick-after-promises order is not an exception. It is the model read correctly, at the one seam where V8, not Node, holds the drain.

Two hosts, two loops

Node's loop had five phases and two extra queues. Reach for the same map in the browser and half of it is missing:

chromium 141 · the Node schedulers you just learned, checked in a real browser page
{ "process":"undefined", "setImmediate":"undefined", "global":"undefined",
  "requestAnimationFrame":"function", "requestIdleCallback":"function",
  "queueMicrotask":"function", "setTimeout":"function", "document":"object",
  "globalThisIsWindow": true }

No process, so no process.nextTick. No setImmediate; it was never standardised. No phases. The browser's loop is almost austere next to libuv's: one task queue and one microtask queue. A task (the spec's word; you may have heard "macrotask") is one unit the loop picks up and runs to completion — a setTimeout callback firing, a click being delivered, a fetched script evaluating. The loop takes one task, runs it, drains all microtasks, takes the next.

But the browser's loop carries one thing Node's has no equivalent for, because Node has no screen. It paints. That single addition reorders what you think you know about timers.

The order, and the rule it breaks

Schedule the usual suspects in a page, plus a requestAnimationFrame:

chromium 141 · five callbacks scheduled from one synchronous run, in-page
console.log('A sync start');
setTimeout(() => console.log('E setTimeout(0)'), 0);
Promise.resolve().then(() => console.log('C promise.then'));
queueMicrotask(() => console.log('D queueMicrotask'));
requestAnimationFrame(() => console.log('F rAF'));
console.log('B sync end');
["A sync start","B sync end","C promise.then","D queueMicrotask","E setTimeout(0)","F rAF"]

Synchronous first (A, B), microtasks drain (C, D), one task (E), then the frame callback (F). Familiar, until you notice F came after E. Every tutorial diagram puts requestAnimationFrame right after the microtasks and before any timer, as if it were a high-priority queue. Here a setTimeout(0) beat it.

requestAnimationFrame is not a queue you drain. It is a callback the browser runs when it is about to paint, and it paints only at a rendering opportunity — a moment tied to the display's refresh, roughly every 16.7 ms at 60 Hz. Between opportunities, ordinary tasks keep running. So a setTimeout(0) runs on the next task, very soon; the rAF waits for the next time the browser decides to draw, which may be several tasks away. Count the tasks that fit between two frames:

chromium 141 · setTimeout(0) tasks fired between consecutive rAF frames, and the frame cadence
{ "tasksBetweenFrames": [4,4,4,4,4], "frameDeltasMs": [16.7,16.6,16.7,16.6,16.7] }

Four tasks per frame, not one. The loop is not "task, render, task, render." It is tasks running as fast as allowed, and a render stepping in every ~16.7 ms whether one task has passed or forty. Rendering is periodic and throttled, not a neighbour of the microtask checkpoint, so rAF-versus-setTimeout(0) has no fixed answer.

The render step, in order

When a rendering opportunity arrives, the browser runs a fixed sequence, and requestAnimationFrame sits at the front of it. That is the reason to use it: you run code after the tasks that mutated state but before the browser reads that state to draw:

the browser's update-the-rendering step, in spec order
run requestAnimationFrame callbacks   ← your animation code, before any measuring
recalculate style                     ← which CSS rules apply
update layout                         ← where every box goes (geometry)
paint                                 ← fill in pixels
composite                             ← stack the layers, hand to the screen

Between frames, microtasks still drain to exhaustion after every task, the browser's "perform a microtask checkpoint," a while loop the spec writes out literally:

While the event loop's microtask queue is not empty: Let oldestMicrotask be the result of dequeuing from the event loop's microtask queue … Run oldestMicrotask.

The two loops side by side. Same language, same run-to-completion, same microtask rule, a different shape around them:

what each host does between your callbacks
   NODE  (libuv)                        BROWSER  (HTML)
   ───────────────────────────          ───────────────────────────
   run a callback                       run ONE task
     drain nextTick → microtasks          drain microtasks (checkpoint)
   advance a phase:                     maybe update the rendering:
     timers   (setTimeout)                run rAF callbacks
     poll     (WAIT for I/O here)         recalc style
     check    (setImmediate)              layout
     close    ('close' handlers)          paint / composite
   (repeat — nothing to paint)          (repeat — render throttled to ~60Hz)

Node's loop moves I/O through phases as fast as the OS delivers it. The browser's keeps a 60 Hz picture smooth while still running your code. Neither is "the event loop"; each is a host's answer to the question the language refused.

0 is not 0, here either

The browser adds its own asterisk to setTimeout(fn, 0). Nest timers, each scheduling the next, and measure the gap between them:

chromium 141 · gap in ms between nested setTimeout(0) calls, nesting levels 1 to 10
[0, 0, 0, 0, 0, 4.2, 4.2, 4.1, 4.4, 4.1]

The first five are near-instant; from the sixth, every one clamps to about 4 ms. The spec sets the floor to stop a runaway chain of zero-delay timers pinning the CPU:

If nestingLevel is greater than 5, and timeout is less than 4, then set timeout to 4.

So a deep chain of setTimeout(0) throttles itself to ~250 calls a second, which is also why four tasks fit in a frame earlier and not four hundred.

One thread, and the frame you dropped

Node's blocked loop missed heartbeats. In the browser the same block has a face: a dropped frame. Run a 50 ms synchronous loop inside two frames and watch the cadence:

chromium 141 · inter-frame gaps in ms; a 50 ms sync loop runs inside frames 3 and 4
[16.7, 16.6, 50, 50.1, 16.6, 16.7, 16.6]

Two frames stretched from 16.7 ms to 50, because script and rendering share the one thread and a synchronous loop cannot be interrupted by the part of the browser that draws. The frame is late. At 60 Hz you have a ~16 ms budget per frame for everything, your task and the render, so 50 ms in a task is a visible stutter. "Don't block the loop" and "keep frames under 16 ms" are the same instruction.

There is a subtler version that catches the careful. Some reads force the browser to compute layout synchronously, now, mid-task. Reading offsetHeight is one: to answer it, the browser must flush pending style and layout before the next line runs. Do it in a loop, interleaving a write and a read, and each read re-triggers a full layout:

chromium 141 · 200 elements: write-then-read each (thrash) vs. all writes then all reads (batched)
{ "count": 200, "thrashMs": 15, "batchedMs": 0.3, "ratio": 50 }

Fifty times slower from the same 200 writes and 200 reads; only the order changed. Interleaving forces 200 layouts, batching forces one. This is the ordering the web platform lives on: not what you compute, but when you force the browser to compute it. Write all your style changes, then read all your geometry, never zip between them.

Reading geometry inside a write loop

el.style.width = …; const h = el.offsetHeight; in a loop is the classic layout-thrash bug. Every offsetHeight read flushes the layout your last write invalidated. The fix is not a faster property; it is separating the phase that writes from the phase that reads.

Same engine, same language, same run-to-completion as Node, wrapped in a different loop — with a paint step that turns "blocking" from a missed heartbeat into a visible stutter, and read/write ordering into your frame budget.

Async I/O: epoll for sockets, a thread pool for the rest

One thread runs your JavaScript, and it must never block. Yet a Node server holds thousands of open connections, reads files, resolves DNS, and hashes passwords, all things that involve waiting. Where does the waiting happen? The two answers you have probably absorbed are both wrong. It is not "a thread per connection"; Node is not that kind of multi-threaded. And it is not "C++ is just fast"; C++ does not make waiting free. The truth is more specific, and it splits cleanly in two.

Sockets: the OS does the watching

The thing that makes an event-driven server possible lives below Node, in the operating system. The kernel can watch a whole set of connections for you and report which are ready. On Linux the interface is epoll: you hand the kernel thousands of socket file descriptors, and instead of checking each, the kernel blocks until any of them has data, then hands back the ready set. One thread, one call, thousands of connections.

That call is the poll phase from chapter 13, where the loop "waits for I/O." It is not a busy-loop and not a thread pool; it is your one thread asleep inside the kernel, woken when a socket is ready. Sockets are never on any pool. Prove it: open 20 connections to a server that delays every response, and time them.

node · 20 concurrent sockets to a slow server, default configuration
pool=4(default)  20 concurrent sockets
  first completed: 325ms   last completed: 327ms   spread: 2ms

All 20 finish within 2 ms of each other. Fed through a pool of 4, you would see five waves of four, ~300 ms apart. You see one wave, because all 20 sat in the same epoll set, multiplexed on the one loop thread. libuv states it flatly:

libuv uses a thread pool to make asynchronous file I/O operations possible, but network I/O is always performed in a single thread, each loop's thread.

This is why Node scales to many connections. Not threads, the opposite: it refuses to dedicate a thread to a connection and lets the kernel watch them all at once.

Files, DNS, crypto: the four helper threads

Sockets have a kernel primitive for "tell me when ready." Reading a file does not — on the common platforms there is no reliable OS-async file read, so someone must make a blocking call and wait. Hashing a password with pbkdf2 has no "ready" event either; it is pure CPU that takes real time. For this second category libuv keeps a thread pool of real OS threads, four by default, and runs the blocking work there so your loop thread stays free. libuv lists what goes on it:

3 types of operations are currently run on this pool: File system operations; DNS functions (getaddrinfo and getnameinfo); User specified code via uv_queue_work.

In Node terms: all of fs (bar the sync calls), dns.lookup (not dns.resolve), the async crypto functions (pbkdf2, scrypt, randomBytes, key generation), and all of zlib. Launch eight equal pbkdf2 jobs:

node · 8 identical crypto jobs, default pool of 4
N=8 jobs, UV_THREADPOOL_SIZE=4 (default)
  job 3 done at 946ms   job 0 at 996ms   job 1 at 997ms   job 2 at 1009ms   ◀ first four
  job 4 at 1859ms   job 5 at 1930ms   job 7 at 1936ms   job 6 at 1960ms     ◀ next four, ~2×

Two waves of four. Four threads run four jobs at once; the rest queue. This is the shape behind a production mystery: "my file reads got slow" when nothing about the files changed, because something else saturated the four shared threads.

The two paths, drawn:

where the waiting happens — one path uses the OS, the other uses threads
   your JavaScript ──▶ libuv
                        │
        socket read/write ├──▶ epoll  (the kernel watches thousands of fds)
                        │        └──▶ poll phase wakes ──▶ your callback
                        │        NO pool · one loop thread
                        │
   fs · dns.lookup · crypto · zlib
                        └──▶ thread pool  ▓▓▓▓  (4 real OS threads, shared, fixed)
                                 └──▶ a thread finishes ──▶ your callback

The pool is CPU, and cores are the ceiling

Since the pool is four real threads, the reflex is to make it bigger. Force it to one and the eight-lane road becomes one, a perfect serial staircase:

node · 4 crypto jobs, pool forced to a single thread
job 0 @922ms   job 1 @1841ms   job 2 @2753ms   job 3 @3678ms

So more threads must be better. Set it to 8 and the batching does vanish, but total time barely moves:

node · 8 crypto jobs, pool raised to 8, on a 4-core machine
all 8 in one batch, finishing 1504ms .. 1945ms

Pool of 4 finished all eight by 1960 ms; pool of 8 by 1945 ms, a tie. Raising the pool bought nothing, because nproc is 4. These are CPU-bound jobs, and eight CPU-hungry threads on four cores take turns on the cores. The pool size decides how many jobs start; the core count decides how fast they finish. For CPU-bound pool work — crypto, compression — the real limit is cores, not the pool.

When a bigger pool does help

Raising UV_THREADPOOL_SIZE helps for I/O-bound pool work: many reads from a slow disk or network filesystem, where threads spend their time waiting, not computing. Set it as an environment variable before startup (UV_THREADPOOL_SIZE=16 node app.js); assigning process.env.UV_THREADPOOL_SIZE inside the process is too late, the pool is already built.

One shared pool, and the bug it causes

The pool is global and shared across your whole program, and that is the sharp edge. Saturate all four threads with crypto, then fire a one-byte file read and open a socket at the same instant:

node · a trivial file read and a socket connect, launched while 4 crypto jobs hold the pool
  socket connect done at 18ms    ◀ NOT on the pool (epoll) — unbothered
  pbkdf2 0 done at 939ms
  fs.readFile done at 944ms       ◀ queued behind the pool; ran only when a thread freed
  pbkdf2 1 @954   pbkdf2 2 @961   pbkdf2 3 @1033

The socket finished in 18 ms, untouched — it was in epoll, not the pool. The one-byte read finished at 944 ms, right after the first crypto job (939 ms) freed a thread. Reading one byte does not take 944 ms; waiting for a pool thread took nearly all of it, because all four were hashing. This is the "my DNS and file ops slowed down under load" incident in eight lines: your dns.lookup and fs reads share four threads with every pbkdf2 and every gzip in the process.

So drop both wrong models. Node is not multi-threaded for your code — your JavaScript is the one loop thread, and network I/O rides the kernel, not threads. And it is not fast because it is C++. It is fast for I/O-bound work because it does not sit and wait: it hands socket-waiting to epoll and hands the unavoidable blocking calls to four helper threads, and spends the meantime turning the loop. The C++ is how libuv reaches those two mechanisms; the mechanisms are the point.

Node's modules: resolution, the wrapper, and the circular half-build

Chapter 10 gave you the language's two module systems side by side: an ESM import is a live binding into another module's variable, a CommonJS require hands back a copy of an object. That is what the spec and the syntax own. This chapter is what Node owns — how require finds a file, what it wraps that file in, why the same file is never run twice, and the failure mode that catches everyone arriving from Python.

Start with a question that looks trivial. In a CommonJS file you write require, module, exports, __dirname, __filename, and none are declared anywhere. They are not in the language: a browser has no require and no __dirname. They are not globals either. So what are they?

Every file is a function body

Ask Node to show you the wrapper it puts around your code:

node · the template every CommonJS file is compiled inside
$ node -e "console.log(require('node:module').wrap('  /* your file body */'))"
(function (exports, require, module, __filename, __dirname) {   /* your file body */
});

Your file does not run at top level. Node wraps it in a function taking those five names as parameters, then calls that function with the right values for this file. __dirname is not magic; it is an argument Node passed in. That is why the five feel global (every file has them) but behave local (each points at its own module and directory). Confirm the identities:

node · the wrapper parameters, observed at runtime
exports === module.exports : true
require.main === module : true
__dirname  : /home/user/guides/work/javascript-in-depth/repro/node/modsys

exports starts as an alias for module.exports, the same object under two names. Hold that; it becomes a footgun shortly.

Finding the file

require(x) runs a resolution algorithm turning the string into an absolute path. Three shapes, three rules:

how a specifier becomes a file
require('node:fs')     core module → returned immediately, never touches disk
require('./util')      relative → try ./util.js, ./util.json, ./util.node,
                                  then ./util/ (its package "main", else index.js)
require('express')      bare name → walk UP the tree looking for node_modules:
                         ./node_modules/express
                         ../node_modules/express
                         ../../node_modules/express   … up to the filesystem root, then throw

That upward walk is why a deeply nested file can require('express') and find the copy at the project root. Watch it succeed and fail:

node · resolving a core module and a missing one
$ node -e "console.log(require.resolve('node:fs')); try{require.resolve('nope')}catch(e){console.log(e.code)}"
node:fs
MODULE_NOT_FOUND

The Python contrast

Python walks a flat list, sys.path, the same for every import in the process. Node walks the directory tree upward from the requiring file, so where a module resolves depends on where you require it from. Same job, different search.

Run once, cached forever

Resolution produces a path, and Node caches by it. The first require runs the file body and stores its module.exports; every later require of the same path skips the body and returns the stored object. A module is a singleton:

node · requiring the same file three times
  counter.cjs body executing (side effect)      ◀ printed once, not three times
a===b===c : true | same n: true
cache keys count for counter: 1

Modules are cached after the first time they are loaded … every call to require('foo') will get exactly the same object returned, if it would resolve to the same file.

This is the feature you rely on without noticing: a config module read from forty files is parsed once, a database pool is genuinely shared. But look at the order of operations, because it is the trapdoor. Node stores the module object in the cache before running the body — it has to, or a file that requires itself would recurse forever. So the cached object exists while the body is still filling it in. Interrupt the body partway, and you can hand out a half-built module.

The circular half-build

Two modules that need each other. orders.cjs builds an Order around a Customer; customers.cjs places an Order. Each requires the other at the top:

node · orders requires customers, which requires back into the still-running orders
orders: running
customers: running
customers: Order is undefined        ◀ orders is cached but half-built
customers: done
orders: Customer is [class Customer]
orders: done
(node:2237) Warning: Accessing non-existent property 'Order' of module exports inside circular dependency

Follow the path. orders starts and, on its first line, requires customers. customers starts and requires back into orders, which is already cached, so Node does not re-run it; it returns the object as it stands now. But now orders has run only its first line — it has not reached module.exports = { Order }. So customers destructures Order off a half-built object and gets undefined:

the require standing on the line where the cycle closes
require('./orders.cjs')
 └─ run orders.cjs body
     line 1  require('./customers.cjs')
              └─ run customers.cjs body
                  line 1  require('./orders.cjs')
                           └─ orders is in cache → return it AS-IS
                              module.exports so far: {}   (Order not defined yet)
                  const { Order } = …   →   Order = undefined

This is the exact twin of the Python guide's circular import, with the same cause — the module object is cached before its body finishes — and the docs use the same word, an unfinished copy of the exports object. But the failure mode is the mirror image, and the difference matters:

  • Python's from orders import Order reads a name that is not there and raises ImportError on the spot. The program stops, with a traceback pointing at the line.
  • Node's const { Order } = require('./orders') destructures a key that is not there and gets undefined. No throw, only a warning printed to stderr, easy to miss in a busy log.

Node is more forgiving and therefore more dangerous. The cycle does not blow up at load time; it hands you undefined and keeps going. The failure surfaces later, elsewhere, when something finally calls new Order(...) and finds it is not a constructor. The fix is Python's: break the cycle, or defer the cross-reference by requiring inside the function that needs it.

Reassigning `exports`

exports and module.exports start as the same object, so exports.save = fn works — it mutates the shared object. But exports = { save: fn } only repoints the local parameter; module.exports still points at the original, and your reassignment is lost. A module that runs exports.works = 1 then exports = { broken: 2 } exports only { works: 1 }. Always assign module.exports = ….

ESM in Node: the async graph

Everything above is CommonJS: synchronous, dynamic, require returning a value. Node's ES-module loader is a different machine, and you met its central fact in chapter 13. An ESM graph is loaded and evaluated asynchronously — Node fetches and parses every module before running any, then evaluates them as promise jobs. That is why top-level await is legal in ESM and not CJS, and why process.nextTick came out last at an ESM top level.

The wrapper globals are gone, replaced by import.meta:

node · what an ES module has instead of require and __dirname
import.meta.url      : file:///…/esm/meta.mjs        (a file: URL, as in browsers)
import.meta.dirname  : /…/esm                        (the CJS __dirname replacement)
import.meta.filename : /…/esm/meta.mjs
import.meta.resolve  : [Function]                     (resolve a specifier to a URL)
typeof require       : undefined
typeof __dirname     : undefined

Crossing between the systems has rules worth knowing before they bite. Importing a CommonJS module from ESM works, but its named exports are guessed by static analysis of the source — Node reads the syntax for module.exports.x = … patterns. Assign through a computed key and the analyzer cannot see it:

node · importing named bindings from a CJS module — statically visible vs. computed
# module.exports.greet = …   (a literal assignment the analyzer can read)
named greet(): hi | named version: 3
# module.exports[name] = …   (computed — invisible to the analyzer)
SyntaxError: Named export 'alpha' not found. The requested module './legacy-dynamic.cjs' is a
CommonJS module, which may not support all module.exports as named exports.

The escape the error suggests: import the default and destructure at runtime. The reverse — pulling ESM into CommonJS — used to be impossible (require() threw on an ES module). On this Node it works unflagged:

node · require() of an ES module, no flags
require(sync ESM) OK: { answer: 42, default: 'hello from ESM' }
  namespace tag: [object Module] | keys: [ '__esModule', 'answer', 'default' ]
require(TLA ESM) FAILED: ERR_REQUIRE_ASYNC_MODULE - require() cannot be used on an ESM graph with
  top-level await. Use import() instead.

A synchronous ESM graph can be required and comes back as a module namespace. A graph with top-level await anywhere cannot: require is synchronous and an awaiting module is not, so you get ERR_REQUIRE_ASYNC_MODULE and must use dynamic import(). This is checked against Node 22.22.2, where require(esm) is on by default; an older Node may need a flag or throw outright, so pin your runtime if you rely on it.

Events and streams: bounded memory over unbounded data

You have spent four chapters on the event loop, so when you meet Node's EventEmitter — the base class behind sockets, streams, and process itself — the name pulls you toward a wrong guess. "Events," a thing you emit, listeners that fire: surely emit() schedules the listeners on the loop, the way setTimeout schedules a callback? Predict the output:

node · does emit() defer its listeners, or run them now?
emitter.on('tick', () => console.log('  listener A'));
emitter.on('tick', () => console.log('  listener B'));
console.log('before emit');
emitter.emit('tick');
console.log('after emit');
before emit
  listener A
  listener B
after emit (listeners already ran — synchronous)

Both listeners ran before emit() returned. emit() is an ordinary synchronous function call: it walks its list of listeners and calls each in registration order, on the current call stack, then returns. Nothing was scheduled; nothing touched the loop. EventEmitter is the plain observer pattern — a list of callbacks and a function that calls them — and it shares nothing with the event loop but the word "event." A thrown error in a listener propagates straight up to the emit() caller, synchronously, which is what you expect from a normal call and not at all what you expect from a queued task.

That correction matters because everything below is built on EventEmitter. A stream is an emitter: 'data', 'end', 'drain', 'error' are its events. Get "emit is synchronous" wrong and the stream events read as mysterious; get it right and they read as ordinary callbacks fired at defined moments.

Why streams exist at all

Read a 4 GB log file into a string to count its lines and you allocate 4 GB of memory. The file is unbounded relative to your RAM; the naive approach scales memory to the data and falls over. A stream breaks that coupling. It moves the data in small chunks with a fixed-size buffer in the middle, so memory stays flat no matter how much flows through. The docs name this as the point of the whole API:

A key goal of the stream API … is to limit the buffering of data to acceptable levels such that sources and destinations of differing speeds will not overwhelm the available memory.

"Sources and destinations of differing speeds" is the crux. A fast disk feeding a slow network, a fast producer and a slow consumer: without a brake, the fast side races ahead and the buffer between them grows without bound. Streams supply the brake. The four kinds are the two directions and two combinations:

Type Direction Example
Readable a source you read from fs.createReadStream, an incoming HTTP request
Writable a sink you write to fs.createWriteStream, an outgoing HTTP response
Duplex both, independent a TCP socket (read what arrives, write what you send)
Transform both, output derived from input gzip, a cipher, a line-splitter

Backpressure is the brake

The mechanism is one signal. When you write to a Writable faster than it can flush, the data queues in an internal buffer. Once that buffer reaches its threshold, the highWaterMark, write() returns false: the stream telling you to stop and wait. Write to a deliberately slow sink (50 ms per chunk, a buffer of 4) in a tight loop:

node · writing faster than the sink can drain
write #0: returned true  (buffered=1/4)
write #1: returned true  (buffered=2/4)
write #2: returned true  (buffered=3/4)
write #3: returned false (buffered=4/4)     ◀ STOP — buffer full
  -> PAUSING until 'drain'
  <- 'drain' @202ms; resuming
write #4..#7 …  all written @404ms

The buffer fills to 4, write() returns false, you stop and wait for 'drain', then resume. The docs are precise about the threshold:

The highWaterMark option is a threshold, not a limit: it dictates the amount of data that a stream buffers before it stops asking for more data.

A threshold, not a limit: you can keep writing after false (Node buffers it), but then you defeat the brake and grow memory without bound. Backpressure is the discipline of honouring the false — write until told to stop, wait for 'drain', resume.

Now the part to run rather than recall, because the common summary is wrong. Many write-ups say 'drain' fires "when the buffer drops below highWaterMark." It does not; it fires at empty. Instrument the buffer length on the way down:

node · buffer length as it drains, and exactly when 'drain' fires
write #3 returned false (writableLength=4)
  … writableLength now 3   (below hwm=4 — but no drain)
  … writableLength now 2   (still no drain)
  … writableLength now 1   (still no drain)
>> 'drain' fired at writableLength=0

The buffer sat at 3, then 2, then 1, all below the highWaterMark of 4, with no 'drain'. It fired only at 0. The docs, read carefully, say exactly this:

Once all currently buffered chunks are drained (accepted for delivery by the operating system), the 'drain' event will be emitted.

"All currently buffered chunks are drained" is empty, not below-threshold, and it matches Node's own source, where the drain condition includes state.length === 0. It rarely changes what you write, but if you ever reason about when 'drain' fires, reason to empty.

Wiring streams together

Doing the backpressure dance by hand — write, check the boolean, pause, wait, resume — is what you should not do by hand. Two tools do it for you. Streams are async-iterable, so for await (const chunk of readable) pulls one chunk at a time, applying backpressure naturally: the next pull waits until you have handled this chunk. And pipeline connects a chain of streams, forwarding data with backpressure and cleaning every stream up if any link errors:

node · a Readable through two Transforms, wired with pipeline
doubled stream: 2 4 6 8 10 | sum = 30
pipeline resolved (all cleanup done)

Reach for pipeline (or stream.promises.pipeline) over pipe for anything real. pipe does not destroy the source if the destination fails, which leaks file descriptors and sockets under error; pipeline propagates the error and tears the whole chain down.

The chunks themselves: Buffer

The chunks flowing through a binary stream are Buffer objects, and a Buffer is not an exotic Node type. It is a subclass of the language's own Uint8Array, a typed array of bytes with extra methods for encoding and slicing:

node · Buffer is a Uint8Array with a wider API
Buffer is global: true | Buffer subclass of Uint8Array: true
Buffer.from("héllo") bytes: 6 | hex: 68c3a96c6c6f

Five characters, six bytes: the é is two bytes in UTF-8 (c3 a9), the code-unit-versus-byte distinction chapter 4 drew for strings, now on the wire. Because a Buffer is a Uint8Array, it works anywhere a typed array does.

The same idea in the browser

The browser has streams too — the WHATWG Streams API: ReadableStream, WritableStream, TransformStream, which is what fetch's response.body is. Same idea exactly (chunks, a buffer, backpressure so a slow reader throttles a fast source), but different objects and a promise-based interface rather than Node's event-and-callback one. There is no Buffer; you work with Uint8Array directly. Moving between hosts, keep the concept and relearn the API.

The through-line is one sentence: streams process data larger than memory by moving it in bounded chunks, and backpressure is the signal that keeps the buffer bounded.

The DOM is host objects, and events arrive as tasks

You write document.querySelector('.card') in a .js file, next to your for loops and your Promises, so the DOM feels like part of the language. It is not. Chapter 1 grepped the ECMAScript spec for "event loop" and found it zero times, and the DOM is the same story: the methods you call every day — querySelector, addEventListener, createElement — appear in ECMA-262 exactly zero times each. They are not the language's. They belong to a separate standard, the WHATWG DOM, and the browser supplies them as host objects injected into the global scope. The cleanest proof is to run the identical language somewhere the browser has not injected them. A Web Worker runs JavaScript, on its own thread, and reports:

chromium 141 · what a Web Worker can see of the DOM
{ "hasDocument": false, "hasWindow": false, "hasSelf": true }

No document, no window. Same engine, same language, but the DOM is absent, because a worker is a different host environment and the browser did not put the page's objects there. document is not a keyword; it is a global variable the page's host defines and the worker's host does not. Node, a third host, defines neither. The DOM is one host's answer to "how do you represent a page," handed to the engine as a graph of objects to manipulate.

A tree of nodes

That graph is a tree. document is the root; every element is a node with links to its parent, children, and siblings; text and comments are nodes too. element.appendChild(child) re-parents a node in that tree, and the browser re-renders because the tree it draws from changed. You drive this API daily, so the thing to pin is its shape: a live, mutable object graph the rendering pipeline reads on every frame (chapter 14's style → layout → paint runs over this tree). Mutating the DOM is how your JavaScript talks to the picture.

A click is a task, and it travels the tree

Here the platform connects back to the loop. When the user clicks, the browser does not call your handler directly. It packages the click as a task and puts it on the event loop (chapter 14). Your handler runs when the loop picks up that task, so a blocked loop makes the page feel dead: the clicks queue as tasks behind whatever is hogging the thread.

When the task runs, the event does not simply fire on the button. It makes a round trip through the tree. Take a <button id=inner> inside <div id=mid> inside <div id=outer>, with a listener on every one, for both directions. Click the button:

chromium 141 · one click, listeners on every element and both phases
outer capture (eventPhase=1 CAPTURING)
mid   capture (eventPhase=1 CAPTURING)
inner capture (eventPhase=2 AT_TARGET)
inner bubble  (eventPhase=2 AT_TARGET)
mid   bubble  (eventPhase=3 BUBBLING)
outer bubble  (eventPhase=3 BUBBLING)

Two passes, opposite directions. The event starts at the root and travels down to the target, the capture phase, then turns around at the target and travels back up to the root, the bubble phase. The DOM spec describes exactly this: walk the path from root to target invoking capturing listeners, then walk it back invoking bubbling ones.

the path a click walks — down in capture, up in bubble
   document
      │   ↓ capture: root → target
   <div outer>
      │
   <div mid>
      │
   <button inner>   ← TARGET (both listeners fire here, phase AT_TARGET)
      │   ↑ bubble: target → root
   … back up to document

Most listeners you write are bubble-phase (the default), so most events have already reached their target and are on the way up. That upward trip is what makes event delegation work: put one listener on a container, and every click inside it bubbles up to that listener, which reads event.target to see which child was clicked. One listener for a thousand rows, added once, working for rows that do not exist yet. And event.stopPropagation() halts the walk where you call it: the same click, with stopPropagation on the button's bubble listener, never reaches mid or outer:

chromium 141 · the same click, stopped at the target
inner bubble        ◀ and nothing above it

The order after your listener

Your click handler runs inside the click task. When it returns, the loop does what chapter 14 described: drains all microtasks (so a Promise.resolve().then() you scheduled in the handler runs before anything else), then, at the next rendering opportunity, paints. A handler that reads geometry it just changed forces layout mid-task — the thrash from chapter 14, now with a user waiting on the click.

How the JavaScript itself arrives

The scripts that add those listeners have to be fetched and run, and when they run depends on one attribute. This is a live source of "my script says document.querySelector is null" bugs: the script ran before the element it wanted existed. The four behaviours, against a real server:

chromium 141 · execution order of four script kinds in one page
0. classic (parser-blocking, runs where it sits)
1. inline-after-tags
2. defer (after parse, in order)
3. module (deferred by default, own scope, strict)
4.   module leaks var to window? false
5.   module is strict? true
6. async (whenever it arrives)
Script Blocks the parser? Runs Order
<script> yes — parsing stops until it loads and runs where it sits in the HTML source order
<script defer> no after the document is parsed source order
<script async> no the moment its fetch finishes whichever loads first
<script type="module"> no — deferred by default after parse source order

A plain <script> in the <head> stops the parser until it downloads and runs, so the old advice was to put scripts at the end of the body or mark them defer. defer lets the parser finish, then runs your scripts in order with the full document available. async runs whenever it arrives, order be damned — right for independent things like analytics, wrong for anything with dependencies. And type="module" is deferred like defer, but also strict by default and given its own top-level scope: a var at a module's top level does not become a window property (module leaks var to window? false above). That isolation is the browser-side face of the same module system chapter 16 covered in Node — different loader, same principle that a module is not the global.

So the platform, stacked on the loop: the DOM is a tree of host objects the engine manipulates and the renderer draws; a user action becomes a task; the task walks the tree down then up, running your listeners; your listeners mutate the tree; microtasks drain; and at the next opportunity the browser paints the change. Every piece is the host's, sitting on the one language and the one loop.

One thread isn't enough: workers and real parallelism

You have a CPU-bound job — count the primes below three million, four times over. Chapter 13 taught you that a synchronous burn like this freezes the one loop thread, so your instinct, sharpened by the async chapters, is to "run them concurrently" with Promise.all. Predict what that buys you over running them one after another:

node · four CPU-bound jobs: in series, then wrapped in Promise.all, then on workers
serial       result=216816  elapsed=2500ms
promise-all  result=216816  elapsed=2541ms   (still one thread)
workers      result=216816  elapsed=743ms    (4 isolates)

Promise.all gave nothing: 2541 ms against 2500. This is the chapter-9 lesson with teeth. A promise changes when work starts, not where it runs. The four prime-counts still execute one at a time on the one thread, because a promise is not a thread. Marking CPU work "async" or gathering it with Promise.all cannot make it parallel; there is no await inside a tight numeric loop for the loop to switch at, so it runs straight through, blocking everything.

If you come from the Python guide, you know this shape and its escape. Python threads do not parallelize CPU work either, because the Global Interpreter Lock lets only one thread run bytecode at a time; the fix there is multiprocessing, leaving the process to get a second interpreter with its own lock. Half of that instinct transfers to Node and half misleads, and the split is the most interesting thing in this chapter.

worker_threads: real parallelism, inside one process

The workers row ran the same work in 743 ms, a 3.4× speedup on four cores. That is genuine parallelism, from worker_threads. The trick is what a worker actually is. It is not a thread sharing your program's memory the way C or Java threads do. Each Worker is a separate V8 isolate: its own heap, its own event loop, its own copy of the engine, on its own OS thread. Four isolates on four cores compute at the same time, because nothing forces them to take turns. The docs name the use case:

Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work.

They do not help with I/O because I/O was never the bottleneck; chapter 15's epoll and thread pool already handle waiting. Workers are for the one thing Node otherwise cannot do: run your JavaScript on more than one core at once.

Now the foil, laid flat, because the contrast with Python is exact:

Threads/workers over CPU work To actually parallelize CPU
Python no speedup — the GIL serializes bytecode separate processes (multiprocessing), each with its own GIL
Node Promise.all over sync work: no speedup — one thread separate isolates (worker_threads) — inside one process

Both languages run your code on one thread by default and give no CPU speedup from their obvious concurrency tool. But Python must leave the process to parallelize, spawning whole new interpreters, because its threads share one interpreter and one lock. Node parallelizes without leaving the process, because a V8 isolate carries no shared interpreter lock — there is nothing for a second isolate to contend on. Node's loop is single-threaded like Python's async, but its escape hatch stays in-process. That is a new shape if the GIL is your only model of "why threads don't help."

The catch: isolates share no memory

The reason there is no lock is the reason workers are less convenient: they share nothing. A separate isolate is a separate heap, and a variable in one is invisible to another. Set a module-level counter = 999 on the main thread, then have four workers read it:

node · four workers reading a variable the main thread set to 999
main counter=999; worker read counter=0 (its own heap — never saw 999)   ×4
SharedArrayBuffer after 4×1000 Atomics.add = 4000 (expected 4000)

Every worker read 0, its own fresh copy, never the main thread's 999. This corrects "workers are threads, so they share memory." Communication is by copying: postMessage sends a structured clone, and the receiver mutates its own copy. Closer to Python's processes than to Python's threads, except in-process.

The one exception is the second line. A SharedArrayBuffer is a block of raw memory genuinely shared between isolates, and Atomics are the operations that read and write it safely. Four workers each did Atomics.add(view, 0, 1) a thousand times, and the total is exactly 4000, no lost updates. Put that beside the Python guide's counter demo, where four threads sharing a variable lost updates and whether they did depended on where the GIL's checkpoint fell. Here safety is not luck: Atomics.add is indivisible by construction. When you need shared mutable state across workers, this is the only channel, and it is safe by design.

The three tools, and when each fits

worker_threads is one of three ways to get work off the main thread:

  • The thread pool (chapter 15) — you write nothing. fs, crypto, zlib, and dns.lookup already run on libuv's four threads. If your "CPU work" is really a built-in async crypto or compression call, it is off your thread already.
  • worker_threads — for your CPU-bound JavaScript, when you want real multi-core parallelism and cheap shared memory (a SharedArrayBuffer) between the workers.
  • child_process — a separate OS process, its own PID, its own V8, over pipes. It can run any executable, not only Node, and a crash in it cannot take your process down:
node · spawning a separate OS process and reading its exit code
child said: "hi from pid 2395"
parent pid=2388  child exit code=7  signal=null

The decision rule falls out of the differences: the pool when the work is already a pooled built-in (nothing to do); worker_threads for multi-core CPU parallelism with shared memory inside one program; child_process for isolation, a non-Node program, or the safety that a child crash stays in the child.

The browser drew the same picture

None of this is Node-specific in shape. The browser reached the identical conclusion under a different name. A Web Worker is a second thread with its own global and its own event loop, no DOM (chapter 18 showed it has no document), created with new Worker(url) and spoken to with postMessage:

chromium 141 · a Web Worker computing while the main thread keeps ticking
{ "hasDocument": false, "hasSelf": true,
  "mainThreadKeptTicking": 53, "originalStillUnmutated": true, "workerSawMutation": true }

The main thread ran 53 timer ticks while the worker computed, genuinely parallel. And originalStillUnmutated: true beside workerSawMutation: true is the copy-not-share rule again: the worker changed its own clone, the main thread's original untouched. Same model as worker_threads down to the details — separate global, postMessage copies, SharedArrayBuffer for real sharing (there gated behind cross-origin isolation headers, a Spectre precaution). Learn the shape once and it transfers between hosts with only the API changing.

So "JavaScript is single-threaded" and "Node does real parallelism" are both true, and now they don't contradict. Your JavaScript runs on one thread; when one thread isn't enough for CPU work, you add more, as isolated agents that share nothing unless you hand them a shared buffer. No interpreter lock to leave the process over, because there is no shared interpreter to lock.

V8 underneath: bytecode, the JIT tiers, and why your types must stay stable

Chapter 1 said the engine runs your code on the one thread. This is how, and it settles an argument you have heard both sides of. Is JavaScript interpreted, and slow? Or compiled to machine code, and fast? The honest answer is both, in that order, for the same function, decided while it runs. V8 has no ahead-of-time compile step — it gets your source at the last moment and must start executing immediately — so it starts by interpreting, watches which functions run hot, and compiles those to machine code on a bet about the types it has seen. Understanding the bet is the chapter, because losing it is the difference between fast code and code that quietly falls back to the interpreter.

Every function starts as bytecode

Ask V8 what state a function is in as you exercise it. Test-only hooks (--allow-natives-syntax) force and read what normally happens on its own:

node · a function's optimization status, from cold to hot
fresh (never called)         IsFunction
after 1 call                 IsFunction | Interpreted
after prepare + warmup       IsFunction | Interpreted
after OptimizeOnNextCall     IsFunction | Optimized | TurboFanned

A fresh function is only a function. Call it and it becomes Interpreted: V8 has parsed it into bytecode, a compact instruction set for its interpreter, Ignition, and is executing that bytecode. This is the "interpreted, therefore slow" half, where every function lives until it earns more. Bytecode is quick to produce, which lets V8 start running your page without a compile pause; the cost is the overhead of decoding and dispatching each instruction.

So V8 watches. A function called enough times is hot, and hot functions are worth compiling to real machine code. Above, forcing that step flips the function to Optimized | TurboFanned. You do not normally force it; it happens on its own when a function warms up. Watch it climb naturally, with V8 narrating:

node · a hot loop promoted with no intervention (--trace-opt)
[marking <JSFunction sum …> for optimization to TURBOFAN, … reason: hot and stable]
[compiling method <JSFunction sum …> (target TURBOFAN) …]
[completed compiling <JSFunction sum …> (target TURBOFAN) - took 0.014, 1.514, 0.015 ms]
[compiling method (target TURBOFAN) OSR …]

"Reason: hot and stable" is V8 stating the bet in three words — this function runs a lot (hot) and its types have not changed (stable), so compiling it will pay off. That last line, OSR, is On-Stack Replacement: V8 optimized a loop while it was still running, swapping the interpreted loop for compiled code mid-flight, not even waiting for the next call.

The ladder has four rungs

"Interpret, then optimize" is really four tiers, each slower to produce and faster to run than the last. V8 climbs them as a function proves itself hotter:

the tier ladder — climbed as a function gets hot
   source
     │  parse
     ▼
   IGNITION    bytecode, interpreted          ← every function starts here
     │  (called a lot?)
     ▼
   SPARKPLUG   baseline machine code          fast to make, barely optimized
     │  (getting hot?)
     ▼
   MAGLEV      fast optimizer                  good code, quickly
     │  (hot AND type-stable?)
     ▼
   TURBOFAN    top optimizer                   slowest to make, fastest to run

The two middle rungs fill the gap between interpreting and the full optimizer, each described by the V8 team in a line. Sparkplug is a baseline compiler that skips optimization to be cheap; it "compiles directly to machine code in a single linear pass over the bytecode," nearly free to produce but only a little faster than Ignition. Maglev sits above it:

Maglev sits between our existing Sparkplug and TurboFan compilers, and fills the role of a fast optimizing compiler that generates good enough code, fast enough.

By V8's own measure it is "roughly 10x slower than Sparkplug, and 10x faster than TurboFan" to compile, a deliberate middle. TurboFan at the top does the heavy analysis and emits the fastest code, worth its cost only for the hottest functions. You manage none of this; V8 promotes and demotes on its own. The reason to know the ladder is the next section: what makes V8 fall back down it.

The two hosts run different V8s

"Node and the browser run the same V8" is true of the architecture, not the version. This Node 22.22.2 has V8 12.4; the Chromium here has V8 14.1 — and Maglev is enabled in the browser but compiled out of this Node. The natural-tier-up trace above went straight to TurboFan with no Maglev rung, and forcing Maglev in this Node does nothing. The browser confirms it does have the rung: { "maglev": true, "sparkplug": true, "turbofan": true }. So the ladder is real everywhere, but which rungs are switched on depends on the build; you can watch Maglev in the browser here, not in this Node.

The bet, and how it breaks

Optimized code is fast because it stops being general. When TurboFan compiles add(a, b) after seeing it called only with numbers, it does not emit code that handles strings, objects, and numbers alike; it emits code that assumes numbers, specifically V8's Smi (Small Integer, a number V8 packs into a machine word without allocating it on the heap). That assumption is baked into the machine code. It is why the code is fast, and it is a bet. Optimize add on numbers, then call it once with strings:

node · an add() optimized for numbers, then handed two strings (--trace-deopt)
[compiling method <JSFunction add …> (target TURBOFAN) …]
optimized status: Optimized
>>> now calling add("x", "y") — violates the number assumption
[bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing <JSFunction add …>,
   <Code TURBOFAN>, opt id 0, bytecode offset 2, …]
status after string call: deoptimized (back to interpreter/baseline)

The string argument is "not a Smi," the exact assumption the compiled code was built on, so V8 performs a deoptimization: it throws the optimized machine code away and drops the function back to the interpreter, mid-call, to finish safely. deopt-eager means it bailed the instant it saw the bad type, at bytecode offset 2. The function is slow again, and V8 must re-earn the right to optimize it; if the types keep changing, it may give up optimizing it at all.

That is the machine-level reason behind every "keep your types monomorphic" rule you have read. It is not superstition. The optimizer specialises on the types a function has actually seen; feed a hot function a stable shape of arguments and it stays compiled; feed it a grab-bag of types and you force repeated deopts, each throwing away work and running the slow path while V8 recompiles. A function that adds numbers should always add numbers. A function that sometimes gets a number, sometimes a string, sometimes undefined is one V8 cannot commit to.

"Stable types" has a second half, at the objects you pass rather than the primitives: two objects can both be "objects" and still be different shapes to V8, and that difference deoptimizes property access the same way a string deoptimizes add. That is the next chapter — what a "shape" is, why the order you build an object in decides it, and how V8 remembers shapes at every property access you write.

Hidden shapes, inline caches, and a GC that pauses your loop

Chapter 20 ended on a promise: "stable types" applies to the objects you pass, not just the primitives, and two things that are both "objects" can be different shapes to V8. That sounds like hair-splitting — objects are hash maps, aren't they, so surely the keys are all that matter and the order you added them is irrelevant. Test it. Build two objects with the same keys in the same order, a third with the same keys in a different order, and ask V8 whether they are the same shape internally:

node · are these the same shape to V8? (--allow-natives-syntax)
a and b same order  -> %HaveSameMap = true
a and c diff order  -> %HaveSameMap = false
a{x,y} vs f{x,y,z}  -> %HaveSameMap = false

Same keys, different insertion order, different shape. Add a third key and it is different again. Construction order is not cosmetic to V8; it is identity. To see why, you have to see how V8 actually stores an object, because it is not the dictionary the language pretends it is.

Hidden classes: structure as a shared fact

When you build objects with a consistent structure, V8 does not give each its own dictionary of key-to-value. It factors the structure out into a shared descriptor — a hidden class (V8 calls it a Map internally, unrelated to the JavaScript Map type) — and stores each object as its values in a flat array, plus a pointer to the shared hidden class saying which key lives at which slot. Ten thousand objects of the same shape share one hidden class and carry only their own values. The V8 team states the rule:

objects with the same structure — e.g. the same named properties in the same order — share the same HiddenClass.

You do not build a hidden class in one step; you walk to it, one property at a time, and each addition transitions to a new class along a transition tree:

how two build orders reach two different hidden classes
   {}                         hidden class  C0  (empty)
    │ add x                    │ add y
    ▼                          ▼
   C1 (x@0)                   C1' (y@0)
    │ add y                    │ add x
    ▼                          ▼
   C2 (x@0, y@0+1)            C2' (y@0, x@0+1)     ← same keys, DIFFERENT class

Every time a new property is added, the object's HiddenClass is changed. In the background V8 creates a transition tree that links the HiddenClasses together.

Build x then y and you follow one path to C2; build y then x and you follow another to C2'. Same final keys, different classes, because you arrived by different routes. This is why a constructor or factory that always assigns the same fields in the same order is fast: every instance lands on the same hidden class. Scatter your assignments and you scatter your objects across shapes.

One shape change is violent enough to avoid on purpose. delete does not only remove a key; it drops the object out of the fast, hidden-class representation entirely, into a dictionary mode where properties are stored the slow, general way:

node · what delete does to an object's representation
e before delete: fastProps=true
e after delete y: fastProps=false

One delete and the object is slow for the rest of its life. On a hot object, prefer setting the field to undefined (which keeps the shape) or rebuilding without the field. delete is a shape-destroyer.

Inline caches: why the call site cares about shapes

Hidden classes would be an implementation detail if they did not show up in your code's speed, and they show up at every property access you write. A line like return o.x is compiled with an inline cache: the first run records "for this hidden class, x is at slot 0," and every later run with the same class is a class-check and a slot-read, a monomorphic access, the fast case. Feed the same line many different shapes and the cache can no longer hold one answer; past a handful it degrades to megamorphic, giving up the cache and doing a full generic lookup every time. Same code, same work, only the variety of shapes differs:

node · one property-access line, fed 1 shape vs. 8 shapes
monomorphic (1 shape) : 114.9 ms
megamorphic (8 shapes): 430.4 ms
slowdown              : 3.74x

Nearly four times slower to read o.x the same number of times, purely because eight hidden classes passed through the access site instead of one. This is the concrete cost of shape instability, and why "keep your objects the same shape" is a performance rule, not a style preference. The optimizer from chapter 20 relies on these caches too: a monomorphic access it can inline into compiled code, a megamorphic one it cannot, another road back to the interpreter.

The finite stack, while we are under the hood

One more engine limit you hit by accident: the call stack is finite. Unbounded recursion does not crash the process — V8 throws a RangeError when the stack is full. How deep depends on frame size: { "depth": 12554 } for a light function, 6975 for one with more locals. It is a catchable error, not a segfault, but a hard ceiling — deep recursion needs converting to a loop or an explicit stack.

The collector, and the thread it stops

Chapter 12 gave you the language's memory model: a value lives as long as it is reachable from a root, and whatever becomes unreachable is the collector's to reclaim. That is the what. Here is the how, and it reaches back into Part II. V8's collector is generational, built on one empirical bet: most objects die young. The intermediate object in a .map().filter(), the arguments to a function that has returned — born, used, unreachable within a moment. So V8 splits the heap. New objects go in a small young generation, collected often and cheaply by a Scavenge. The few that survive get promoted to an old generation, collected rarely and expensively by a Mark-Compact. Run a workload that churns short-lived garbage with a trickle of survivors:

node · which collections ran over a churning workload (--trace-gc)
     77 Scavenge
      1 Mark-Compact
Scavenge sample:     Scavenge 4.4 (5.1) -> 3.6 (6.1) MB … 0.72 ms
Mark-Compact sample: Mark-Compact 13.5 (36.8) -> 3.7 (36.8) MB … 3.00 ms

Seventy-seven cheap young collections to one expensive full one, the generational bet paying off in a single ratio. Most garbage never left the young generation, and reclaiming it there cost about 0.72 ms a time; the one full collection cost 3.00 ms.

Now read those milliseconds again and ask which thread they ran on. The same one. V8's collector pauses the main thread to work — the same thread the event loop turns on, the same thread your JavaScript runs on, the same thread with ~16 ms to produce a frame. A 3 ms Mark-Compact is 3 ms in which no callback runs, no task is picked up, no frame is painted. Garbage collection is not only a memory concern; it is a latency concern, because a GC pause is a heartbeat missed (chapter 13) and a frame dropped (chapter 14). This is why allocation discipline in a hot path — reusing objects, avoiding per-frame garbage — is really loop discipline: every object you do not allocate is a pause you do not take on the thread that can least afford it.

That closes the arc the guide has walked since chapter 1. One language, small and defined: values, objects, run-to-completion, a job queue. One thread it runs on. A host that wraps that thread in a loop — libuv's phases in Node, the HTML loop with its render step in the browser — and supplies the timers, the I/O, the modules, the DOM. And one engine underneath both, interpreting your code and then compiling it on bets about your types and shapes, collecting the garbage on the very thread the loop depends on. The language you already wrote daily was the visible tenth of it. The host and the engine were the nine-tenths under the water, and now you have seen them run.

Further reading

The primary sources behind this guide, grouped by the three movements. Every claim in the text was run in Node 22 and real Chromium, or read against one of these.

The language — the ECMAScript specification. The living standard, and more readable than its reputation. Go to it for the exact algorithms this guide leans on: IsLooselyEqual (why == does what it does), ToPrimitive, the Number and String type definitions (IEEE-754 and UTF-16), Jobs and Host Operations to Enqueue Jobs (the microtask contract the whole async story rests on), Module Environment Records (live bindings), and CreatePerIterationEnvironment (the let-per-iteration fix).

Node — the API documentation, pinned to your version. process for the nextTick/microtask ordering; modules for CommonJS resolution and the cycle behavior; esm for the ESM/CommonJS interop rules; stream for backpressure and 'drain'; worker_threads for real parallelism. The companion Event Loop, Timers, and process.nextTick() guide is the readable overview.

Node — libuv. The event loop's design: the phase order, and what runs on the thread pool (file I/O, DNS, user work) versus what does not (sockets, multiplexed on the loop's own thread).

The browser — the WHATWG HTML Standard, "event loop processing model." Tasks and the microtask checkpoint, "update the rendering" (where requestAnimationFrame sits), and the timer 4 ms clamp. The DOM Standard has the event-dispatch algorithm — capture, target, bubble.

The engine — the V8 blog. "Sparkplug," "Maglev," and "Fast properties in V8" (hidden classes) are the clearest first-party account of the tiers and object shapes.

The sibling guide. Python, from the object up is the foil throughout: the same questions — closures, iteration, await, threads and a global lock — answered by a different language. A comparison, not a prerequisite.