Most JavaScript bugs that survive code review are not algorithm bugs. They are object-model bugs: a callback that lost its this, a class field that silently shadowed a base-class setter, a "deep copy" that was one level deep, a Proxy that broke private fields, a cache that kept every request alive. Each of these follows directly from rules in the specification that are short, precise and rarely taught in full. This post states those rules: environments and the temporal dead zone, the four this bindings, the prototype chain and what class really builds, private fields and static blocks, property descriptors, symbols and the iteration protocols, Proxy and Reflect with their invariants, weak references, and the differences between structuredClone, spread and JSON copies.

Series map: 00 Engines & Runtime → 01 Event Loop & Concurrency → 02 Objects, Prototypes & Metaprogramming (this post) → 03 Async Engineering → 04 Modules, Packaging & TypeScript at Scale → 05 Performance, Memory & Production Debugging. Future: see the extensions table in 05.


Learning Outcome

  • Explain scopes as environment records, and state exactly when the temporal dead zone (TDZ) applies.
  • Recognise the closure-retention leak caused by shared scope contexts.
  • Apply the four this rules in order of precedence, plus arrow functions and class fields.
  • Draw the prototype chain for a class hierarchy, including the constructor-side chain.
  • Use private #fields, #x in obj brand checks, static blocks and accessors, and know how class fields differ from assignments.
  • Read and write property descriptors; choose between preventExtensions, seal and freeze.
  • Implement the iterator protocol, use generators for lazy pipelines, and hook into well-known symbols.
  • Build a Proxy that forwards correctly with Reflect, and respect its invariants.
  • Choose between WeakMap, WeakRef and FinalizationRegistry, and between structuredClone, spread and JSON for copies.

Chapter 1 — Scope, closures and the TDZ

Terminology

  • Environment record — the specification's name for a scope: a map from names to bindings. Function calls, blocks, modules and the global scope each create one. Each record points to its outer record; name lookup walks that chain.
  • Binding — a named slot. var and function declarations create bindings that are initialised to undefined (or the function) when the scope is entered. let, const and class create bindings that stay uninitialised until their declaration runs.
  • TDZ (temporal dead zone) — the time between entering a scope and executing a let/const/class declaration. Any access to the binding in that window throws ReferenceError.
  • Closure — a function object plus a reference to the environment where it was created.

The TDZ, precisely

The TDZ is temporal, not textual: what matters is when the access runs, not where it is written.

1
2
3
4
5
6
7
8
9
10
11
12
function read() { return value; }   // textually before the declaration
const value = 42;
read(); // 42: the call happens after initialisation

{
typeof t; // ReferenceError: Cannot access 't' before initialization
let t = 1; // (typeof does NOT protect a TDZ binding, unlike an undeclared name)
}

function f(a = b, b = 1) {} // parameters are initialised left to right
f(); // ReferenceError: Cannot access 'b' before initialization

Other TDZ cases that bite in real code: a class used before its declaration in the same module (classes are not hoisted like functions), and circular ES module imports, where a module reads an imported const before the exporting module has evaluated it — the error message names the binding, not the cycle.

Closures capture bindings, not values

1
2
3
4
5
6
7
8
const fns = [];
for (let i = 0; i < 3; i++) fns.push(() => i);
fns.map(f => f()); // [0, 1, 2]: `let` in a for-head gets a fresh binding per iteration

const gs = [];
for (var j = 0; j < 3; j++) gs.push(() => j);
gs.map(g => g()); // [3, 3, 3]: one function-scoped binding, read after the loop ends

The shared-context leak

V8 (like other engines) does not give each closure its own copy of the variables it uses. Variables captured by any closure in a scope go into one context object for that scope, and every closure created in that scope keeps the whole context alive:

1
2
3
4
5
6
function outer() {
const big = new Array(1e6).fill(0);
const unused = () => big.length; // captures `big` into outer's context
return () => 1; // never mentions `big`, but shares the context
}

Keeping 20 of the returned closures alive retained about 164 MB of heap in Node 22; the same code without unused retained about 4 MB. Real versions of this leak involve an event handler or a timer callback created in the same function as a closure that touched a large response or DOM tree.

Trick: when a long-lived callback is created in a function that also handles big data, move the callback's creation into a small separate function that receives only what it needs. The new scope has its own context, and the big object is no longer reachable.


Chapter 2 — this

this is not lexical (except in arrow functions). It is decided at call time, by how the function is called. In order of precedence:

Rule Call shape this is
1. new new F() The newly created object (a bound function's bound this is ignored)
2. Explicit f.call(x), f.apply(x), f.bind(x)() x
3. Implicit (method) obj.f(), obj['f']() obj — the object before the dot, at the moment of the call
4. Default f() undefined in strict code (modules and classes are always strict); globalThis in sloppy scripts
Arrow functions any The this of the enclosing scope; call/bind cannot change it
1
2
3
4
5
6
7
8
9
const o = { name: 'o', f() { return this?.name; } };
const f = o.f;
f(); // undefined: rule 4, the "lost this"
[1].map(o.f); // [undefined]: same, the callback is called bare

function Foo() { this.a = 1; }
const Bound = Foo.bind({ a: 99 });
new Bound().a; // 1: `new` beats `bind`

Where it goes wrong in practice:

  • Passing methods as callbacks — button.addEventListener('click', this.onClick), promise.then(service.load), setTimeout(obj.tick, 100). For DOM listeners this becomes the element; for the others, undefined.
  • Destructuring methods — const { log } = console works only because console methods happen not to rely on this in modern engines; const { push } = arr does not.

Fixes, with their trade-offs:

1
2
3
4
5
6
7
8
9
class Clicker {
count = 0;
onClick = () => { this.count++; }; // arrow in a class field: one function PER INSTANCE
onKey() { this.count++; } // method: one function on the prototype, shared
}
const c = new Clicker();
el.addEventListener('click', c.onClick); // works
el.addEventListener('keydown', () => c.onKey()); // wrapper at the call site

Watch out: arrow-function class fields are convenient, but they live on each instance, not on the prototype. They cannot be overridden with super.onClick() in a subclass, do not appear on the prototype for mocking in tests, and cost one closure per instance — noticeable for classes instantiated thousands of times.


Chapter 3 — The prototype chain

Every object has an internal slot [[Prototype]] — another object or null. Reading a property walks the chain until it finds an own property with that key; writing does something subtler.

What class builds

1
2
3
4
5
6
7
8
9
10
11
class Base {
constructor(id) { this.id = id; }
describe() { return `#${this.id}`; }
static create(id) { return new this(id); }
}
class User extends Base {
constructor(id, name) { super(id); this.name = name; }
describe() { return `${super.describe()} ${this.name}`; }
}
const u = new User(7, 'Ada');

Prototype chain for a class hierarchy: instance to User.prototype to Base.prototype to Object.prototype to null, and the constructor chain User to Base to Function.prototype

There are two chains:

  • Instance side: u → User.prototype (has describe) → Base.prototype (has describe) → Object.prototype → null.
  • Constructor side: User → Base → Function.prototype → Object.prototype → null. This is why User.create(1) works: static methods are inherited because Object.getPrototypeOf(User) === Base.

Facts that follow from the spec and matter in code review:

  • Class methods are non-enumerable, so they never show up in for…in, Object.keys or spread.
  • Class bodies are strict mode, and calling a class without new throws TypeError: Class constructor User cannot be invoked without 'new'.
  • super.describe() is resolved through the method's home object (the object it was defined on), not through this, so it keeps working when the method is borrowed.
  • instanceof checks whether F.prototype appears on the chain (unless F defines Symbol.hasInstance). It fails across realms — an array from an iframe is not instanceof Array in the parent. Prefer Array.isArray, or brand checks (Chapter 4).

Writing is not symmetric with reading

obj.x = v does not simply create an own property. If x is found on the prototype chain:

Found on the chain as Result of obj.x = v
Nothing Creates an own data property on obj
A writable data property Creates an own property on obj (shadowing)
A non-writable data property Assignment fails: TypeError in strict mode, silently ignored in sloppy mode. No own property is created
An accessor (setter) Calls the inherited setter with this = obj; no own property is created
1
2
3
4
5
6
7
const proto = {};
Object.defineProperty(proto, 'id', { value: 1, writable: false });
const obj = Object.create(proto);
obj.id = 2; // TypeError: Cannot assign to read only property 'id' of object
Object.defineProperty(obj, 'id', { value: 2, writable: true, enumerable: true, configurable: true });
obj.id; // 2: defineProperty creates the own property regardless

This asymmetry between assignment ([[Set]], follows the chain) and definition ([[DefineOwnProperty]], does not) is the key to class fields in the next chapter.

Common pitfall: using a plain object as a dictionary of user-supplied keys. dict['__proto__'] = value hits the inherited __proto__ accessor and changes the object's prototype instead of storing a key — the root of many prototype-pollution vulnerabilities. Use Map, or Object.create(null) for a chain-free object.


Chapter 4 — Classes in depth

Fields are defined, not assigned

Class fields use define semantics (like Object.defineProperty), not assignment:

1
2
3
4
5
class Base { set v(x) { console.log('setter called', x); } }
class Derived extends Base { v = 5; }
const d = new Derived(); // nothing logged: the setter is NOT called
Object.getOwnPropertyDescriptor(d, 'v'); // { value: 5, writable: true, enumerable: true, configurable: true }

The own property v now shadows the base-class accessor. Code migrated from constructor assignments (this.v = 5, which would call the setter) to fields changes behaviour. TypeScript follows the standard when useDefineForClassFields is true (the default for targets ES2022 and later); older configurations emitted assignments, so upgrading the target can change behaviour for exactly this pattern.

Private members and brand checks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Account {
#balance = 0; // private field: per instance, not on the prototype
static #registry = new Set(); // private static field

static { // static initialization block (ES2022)
Account.#registry.clear(); // runs once, with access to private members
}

#audit(msg) { /* private method */ }

deposit(n) {
if (!(n > 0)) throw new RangeError('amount must be positive');
this.#balance += n;
this.#audit(`deposit ${n}`);
}
get balance() { return this.#balance; } // accessor

static isAccount(x) { return #balance in x; } // ergonomic brand check (ES2022)
}

Private names are lexically scoped to the class body and are not properties at all: they are not reachable by Object.keys, Reflect.ownKeys, Proxy traps or structuredClone. Accessing #balance on an object that lacks it throws TypeError — which makes #x in obj a reliable, realm-independent brand check, unlike instanceof.

Watch out: private fields and Proxy do not mix. A method called through a proxy has this === proxy, and the proxy object has no private fields:

1
2
3
4
class Counter { #n = 0; inc() { return ++this.#n; } }
new Proxy(new Counter(), {}).inc();
// TypeError: Cannot read private member #n from an object whose class did not declare it

Any library that wraps your objects in proxies (Vue's reactive() is the best-known example) hits exactly this. Either keep such classes out of proxied state, or have their methods work on the raw target.


Chapter 5 — Property descriptors and integrity levels

Every own property has a descriptor. Data properties have value and writable; accessor properties have get and set; both have enumerable and configurable.

Created by writable enumerable configurable
Assignment or object literal true true true
Class field true true true
Class method true false true
Object.defineProperty with fields omitted false false false

The last row is a frequent surprise: Object.defineProperty(o, 'k', { value: 1 }) creates a read-only, hidden, permanent property. Object.keys(o) then returns [].

configurable: false means the property cannot be deleted and its descriptor can no longer change — with one exception: a writable data property may still be made non-writable.

Integrity levels

Add properties Delete Reconfigure Change values
Object.preventExtensions(o) No Yes Yes Yes
Object.seal(o) No No No Yes
Object.freeze(o) No No No No (data properties)

All three are shallow: Object.freeze({ inner: { x: 1 } }).inner.x = 2 succeeds. Freezing also does not stop accessor properties from changing internal state. A deep freeze must recurse, and it should be a development-time guard (freezing Redux state in dev builds is a common use), not a security boundary.


Chapter 6 — Symbols, iterators and generators

Symbols

A Symbol() is a unique, non-string property key. Symbol('x') !== Symbol('x'); Symbol.for('x') returns a shared, registry-wide symbol (the same across realms). Symbol-keyed properties are skipped by Object.keys, for…in and JSON.stringify, but are copied by spread and Object.assign, and listed by Reflect.ownKeys. They are hidden from casual enumeration, not private.

Well-known symbols are the engine's hooks into your objects:

Symbol Hook Used by
Symbol.iterator Return an iterator for…of, spread, destructuring, Array.from, Promise.all
Symbol.asyncIterator Return an async iterator for await…of
Symbol.toPrimitive Convert to a primitive, given a hint ('number', 'string', 'default') +x, template literals, x + ''
Symbol.hasInstance Custom instanceof x instanceof C
Symbol.toStringTag Tag for Object.prototype.toString [object Tag] in logs and type checks
Symbol.species Constructor for derived objects map/filter on Array subclasses, then on Promise subclasses
Symbol.dispose, Symbol.asyncDispose Cleanup hook using / await using declarations (explicit resource management; shipped in Chrome 134 and Node 24, check other engines; Post 03)
1
2
3
4
5
6
7
8
9
const price = {
[Symbol.toPrimitive](hint) {
return hint === 'number' ? 42 : hint === 'string' ? '$42' : 'default';
},
};
+price; // 42
`${price}`; // '$42'
price + ''; // 'default' (binary + uses the 'default' hint)

The iteration protocols

An iterable has a [Symbol.iterator]() method returning an iterator: an object with next() returning { value, done }, and optionally return(). for…of calls return() when the loop exits early (break, return, throw) — this is how iterators release resources.

1
2
3
4
5
6
7
8
9
10
11
const ticks = {
[Symbol.iterator]() {
let i = 0;
return {
next: () => ({ value: i++, done: false }),
return() { console.log('cleanup'); return { done: true }; },
};
},
};
for (const t of ticks) if (t === 2) break; // logs "cleanup"

Generators

A generator function returns an object that is both iterable and an iterator, with the protocol implemented for you. It runs lazily, one yield at a time, and its finally blocks run when a consumer stops early:

1
2
3
4
5
6
7
8
9
function* readLines(text) {
const handle = open(text); // pretend resource
try {
for (const line of handle) yield line;
} finally {
handle.close(); // runs on normal end, on break, and on throw
}
}

Since ES2025, iterator helpers give every built-in iterator lazy map, filter, take, drop, flatMap, reduce, toArray, forEach, some, every and find, and Iterator.from() wraps any iterable:

1
2
3
function* naturals() { let n = 0; while (true) yield n++; }
naturals().filter(n => n % 2).map(n => n * n).take(3).toArray(); // [1, 9, 25]

Nothing is computed until toArray() pulls values, so an infinite source is fine. Unlike array methods, no intermediate arrays are allocated.

Trick: yield* delegates to another iterable and forwards return() and throw() to it, so composed generators still clean up correctly. Hand-written iterator wrappers usually forget to forward return() and leak the inner resource.


Chapter 7 — Proxy and Reflect

A Proxy wraps a target and intercepts the object's internal methods through traps: get, set, has, deleteProperty, ownKeys, getOwnPropertyDescriptor, defineProperty, getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, and for functions apply and construct. Reflect has a method with the same name and signature for every trap, performing the default behaviour.

Forward with Reflect, and pass the receiver

1
2
3
4
5
6
7
8
9
10
11
12
const person = { first: 'Ada', last: 'Lovelace', get full() { return `${this.first} ${this.last}`; } };

const wrong = new Proxy(person, {
get(target, key) { return key === 'first' ? 'ADA' : target[key]; },
});
wrong.full; // 'Ada Lovelace': the getter ran with this = target, bypassing the proxy

const right = new Proxy(person, {
get(target, key, receiver) { return key === 'first' ? 'ADA' : Reflect.get(target, key, receiver); },
});
right.full; // 'ADA Lovelace': the getter ran with this = proxy, so its reads were trapped too

The receiver argument is the object the operation started on. Forwarding it is what makes getters, setters and inheritance (a proxy used as a prototype) behave correctly.

Invariants

A proxy may lie, but not about facts the target has made permanent. The engine checks each trap's result against the target and throws TypeError on a violation. The main ones:

  • get must return the actual value for a non-writable, non-configurable own data property of the target.
  • has and getOwnPropertyDescriptor cannot hide a non-configurable property, or any property of a non-extensible target.
  • ownKeys must include every non-configurable own key; for a non-extensible target it must list exactly the target's keys.
  • isExtensible must match the target; getPrototypeOf must match for a non-extensible target.
1
2
3
4
5
const frozen = Object.freeze({ k: 1 });
new Proxy(frozen, { get: () => 2 }).k;
// TypeError: 'get' on proxy: property 'k' is a read-only and non-configurable data property
// on the proxy target but the proxy did not return its actual value (expected '1' but got '2')

This is why you cannot make a "read-only view" of a frozen object that rewrites values, and why reactive systems proxy mutable targets.

Costs and limits

  • Identity: proxy !== target. Sets, maps and === checks see two different objects; libraries keep a WeakMap from target to proxy to return the same proxy each time.
  • Internal slots: built-ins keep data in internal slots that a proxy does not have. new Proxy(new Map(), {}).get(1) throws (Map.prototype.get called on an incompatible receiver); the same applies to Date, Set, typed arrays and private fields.
  • Speed: every trapped operation is a function call, and proxied access is much slower than a plain property load in optimised code (Post 00's inline caches cannot see through a trap). Keep proxies at boundaries — validation, reactivity, API clients — not in hot inner loops.
  • Proxy.revocable(target, handler) returns { proxy, revoke }; after revoke(), every operation on the proxy throws. Useful for handing out capabilities that can be withdrawn.

Chapter 8 — Weak references and copying

Weak collections

Keys / target Enumerable Typical use
WeakMap Objects and non-registered symbols No Per-object metadata and caches that die with the object
WeakSet Same No Marking objects ("already visited", "already wrapped")
WeakRef One object — A cache entry that may disappear; deref() returns the object or undefined
FinalizationRegistry Callback after an object is collected — Releasing an external resource as a backstop

A WeakMap entry keeps its value alive only while the key is alive — the right structure for "extra data about objects I do not own" (DOM nodes, request objects, library instances):

1
2
3
4
const meta = new WeakMap();
function track(req) { meta.set(req, { start: performance.now() }); }
// when `req` becomes unreachable, its entry is collectable too: no manual delete, no leak

WeakRef and FinalizationRegistry are deliberately weak guarantees. The specification allows a finalization callback to run late or never (for example, when the page closes). Within one synchronous job, deref() keeps returning the same object (the engine keeps it alive until the job ends), so check once and use the result. Never put essential cleanup — closing a file, releasing a lock — in a finalizer; use try/finally or using, and treat the registry as a leak detector or a last-resort backstop.

Copying: structuredClone vs spread vs JSON

Spread / Object.assign JSON.parse(JSON.stringify(x)) structuredClone(x)
Depth Shallow Deep Deep
Date Same object shared Becomes a string Date
Map, Set Same object shared Becomes {} Map, Set
undefined values Kept Key dropped Kept
NaN, Infinity Kept Become null Kept
BigInt Kept Throws Kept
Circular references Shared (shallow) Throws Preserved
Functions Copied by reference Dropped DataCloneError
Getters Invoked; value copied Invoked; value copied Invoked; value copied
Prototype / class Lost (plain object) Lost Lost
Symbol keys Copied Dropped Dropped
Private #fields Not copied Not copied Not copied

(Cells checked in Node 22.) The practical rules:

  • Spread for one level of immutable update ({ ...state, user: { ...state.user, name } }), which is what React and Redux reducers need.
  • structuredClone for a real deep copy of data — including Date, Map, Set, typed arrays, cycles.
  • Neither preserves behaviour: class instances come back as plain objects. To copy an instance, give the class its own clone() method.
  • JSON round-trips are for serialisation, not copying.

Practice Exercises

Exercise 1 — TDZ or not?

Which lines throw?

1
2
3
4
5
6
7
console.log(typeof undeclared);   // (a)
console.log(typeof later); // (b)
let later = 1;
function g() { return helper(); } // (c) g is called at the end
const helper = () => 'ok';
g();
Solution

Only (b) throws ReferenceError: later is declared with let in the same scope and is still in the TDZ. (a) prints 'undefined' — typeof is safe for names that are not declared at all. (c) works: helper is read when g() runs, after its initialisation. The TDZ is about time, not position.

Exercise 2 — What is this?

1
2
3
4
5
6
7
8
9
'use strict';
const counter = {
n: 0,
inc() { this.n++; return this.n; },
later() { setTimeout(function () { console.log(this?.n); }, 0); },
laterArrow() { setTimeout(() => console.log(this.n), 0); },
};
const { inc } = counter;

What do counter.inc(), inc(), counter.later() and counter.laterArrow() do?

Solution

counter.inc() returns 1 (implicit binding). inc() throws TypeError (this is undefined in strict code, so this.n fails). counter.later() logs undefined: the callback is a plain function called by the timer (in Node this is the Timeout object; in browsers it is window), neither of which has n. counter.laterArrow() logs the current n, because the arrow takes this from laterArrow, which was called as a method.

Exercise 3 — The setter that stopped firing

A base class has set value(v) { this.#validate(v); … }. A subclass declares value = 0; as a field. Validation stopped running after a TypeScript target upgrade to ES2022. Why, and what are two fixes?

Solution

With useDefineForClassFields (default for ES2022+ targets), the field is defined as an own data property on the instance, which shadows the inherited accessor; the setter never runs, now or on later assignments. Fixes: (1) remove the field and assign in the constructor (constructor() { super(); this.value = 0; }), which goes through [[Set]] and calls the setter; or (2) use TypeScript's declare value: number; if the field was only there for typing, which emits nothing.

Exercise 4 — A logging proxy that works

Write logged(obj) returning a proxy that logs every property read and write, works with getters and setters that use this, and returns the same proxy when called twice with the same object.

Solution
1
2
3
4
5
6
7
8
9
10
11
const cache = new WeakMap();
function logged(obj) {
if (cache.has(obj)) return cache.get(obj);
const proxy = new Proxy(obj, {
get(t, k, r) { console.log('get', k); return Reflect.get(t, k, r); },
set(t, k, v, r) { console.log('set', k, v); return Reflect.set(t, k, v, r); },
});
cache.set(obj, proxy);
return proxy;
}

Passing receiver to Reflect.get/Reflect.set makes accessors run with this = proxy, so their inner reads are logged too. The WeakMap keeps proxy identity stable without keeping obj alive. It still fails for objects with private fields or internal slots (Map, Date) — by design of Proxy.

Exercise 5 — A lazy pipeline

Using generators and iterator helpers, write chunks(iterable, size) that yields arrays of size items, and use it to process the first 3 chunks of an infinite sequence without computing more.

Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
function* chunks(iterable, size) {
let buf = [];
for (const x of iterable) {
buf.push(x);
if (buf.length === size) { yield buf; buf = []; }
}
if (buf.length) yield buf;
}
function* naturals() { let n = 0; while (true) yield n++; }

Iterator.from(chunks(naturals(), 4)).take(3).toArray();
// [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]

take(3) calls return() on the chunk generator after the third chunk, which in turn exits its for…of, which calls return() on naturals(). The infinite source stops cleanly.

Exercise 6 — Pick the copy

For each, choose spread, structuredClone or neither: (a) update one field of a Redux state slice; (b) snapshot a form's state (with Date and Set values) for undo; (c) copy an instance of class Money with methods; (d) send a nested config object to a worker.

Solution

(a) Spread — shallow is exactly right, and unchanged branches keep their identity for cheap equality checks. (b) structuredClone — deep, keeps Date and Set. (c) Neither — both return plain objects; add clone() to the class. (d) Neither by hand — postMessage already applies structured clone; just make sure the object holds no functions or class instances whose behaviour you rely on.


Cheat-sheet

Topic Rule
TDZ let/const/class unusable until the declaration runs; typeof does not help
Closure leak Closures in one scope share one context; move long-lived callbacks to their own function
this precedence new > call/apply/bind > obj.f() > default (undefined in strict); arrows are lexical
Assignment vs definition obj.x = v follows the chain (setters, read-only); defineProperty and class fields do not
Brand check #field in obj (realm-safe) instead of instanceof
defineProperty defaults writable, enumerable, configurable all false
Integrity preventExtensions < seal < freeze; all shallow
Iteration [Symbol.iterator]() → { next, return }; for…of calls return() on early exit
Lazy pipelines Generators + iterator helpers (map, filter, take, toArray), ES2025
Proxy forwarding Reflect.<trap>(target, …args, receiver)
Proxy limits Invariants on non-configurable props; no private fields or internal slots; slower
Finalizers Not guaranteed to run; backstop only
Deep copy of data structuredClone (no functions, no prototypes)

What's next

Post 03 is Async Engineering: promises as a state machine and their combinators, async iteration and streams, cancellation with AbortController and AbortSignal, backpressure and concurrency limits, retries with jitter, using / await using for deterministic cleanup (built on Symbol.dispose from this post), and error handling that survives production — unhandled rejections, error cause chains and aggregate errors. It builds on the queue model from Post 01 and the iterator protocols from this post.


References