Part 9 · 5 chapters · ~40 min

Iteration And Generators

Every for...of is a protocol, every generator is a frame that pauses, and every for await is that protocol with a promise per step. This part is what for...of compiles to and the protector cells that make it free over arrays, the generator object and what yield saves, async iteration and its per-item cost and its backpressure, every iteration form compared in both tiers, and the tools.

64

The iterator protocol, and what for...of really does

the question

"Is for...of slower than a for loop with an index? Everyone says so and nobody measures it."

In the interpreter, yes: a call and two property loads per element against a compare and a load. In optimised code over a plain array, no: TurboFan recognises the built-in array iterator and compiles the loop to the same code as an index loop. The answer depends on the tier, and on whether anyone in the process has modified Array.prototype. This part is the protocol, the machinery for generators and async iteration, and the fast paths that make the protocol free when they apply.

code
// the protocol, spelled out
const iterable = { [Symbol.iterator]() { let i = 0; return { next: () => i < 3 ? { value: i++, done: false } : { value: undefined, done: true }, return() { cleanup(); return { done: true } } } } }
for (const x of iterable) { if (x === 1) break }   // next, next, then return() because of break

// a generator is the same protocol with the engine writing next/return/throw for you
function* iterable2() { try { yield 0; yield 1; yield 2 } finally { cleanup() } }

// consumers of the protocol (all use GetIterator + next, with the same array fast paths):
[...it]  Array.from(it)  const [a, b] = it  new Set(it)  new Map(it)  Promise.all(it)  for (const x of it)  yield* it

// what does NOT use it: for...in (keys, by enumeration cache), Array.prototype.forEach/map (index-based, length read once),
// Object.entries (returns an array), arr.entries()/keys()/values() (return array iterators)
the protocol
  1. Iterable: an object with a [Symbol.iterator]() method returning an iterator. Arrays, strings, Maps, Sets, typed arrays, arguments, NodeLists, generators, and anything you give the method to.
  2. Iterator: an object with next() returning {value, done}; optionally return() (called on early exit) and throw().
  3. The loop: GetIterator once; per step: next(), read done, read value, body; on break, throw or return from inside the body, call return() if it exists, then propagate.
  4. Destructuring, spread, Array.from, Promise.all, new Map(...): all consumers of the same protocol, all with the same fast paths.
the fast paths and their guards
  1. Protector cells: global boolean cells that record "nobody has modified X": Array.prototype[Symbol.iterator], %ArrayIteratorPrototype%.next, Array.prototype having no indexed elements, Symbol.species on Array and Promise, and a few more. Builtins and TurboFan check the cell once and take the fast path. Any script that patches one of those (a polyfill, an old library, a monkey-patch) invalidates the cell process-wide and permanently.
  2. Array iteration in TurboFan: protector valid + stable elements kind → next() inlined to an index increment and bounds check; {value, done} eliminated by escape analysis; value loaded directly from the elements. Identical to an index loop.
  3. Spread and Array.from on arrays: a memcpy-like fast path under the same protector.
  4. Maps and Sets: their iterators are also recognised; iteration over a Map in optimised code walks the hash table's entries array directly.
  5. Strings: iterate by code point (not UTF-16 unit); a fast path for one-byte strings.
run it
Benchmark a sum over an array with an index loop and with for...of, in a function called a few thousand times (so it reaches TurboFan), under --trace-opt. Then run the same with --no-opt --no-maglev. Two sets of numbers: close together in the first, several times apart in the second. Both are true; which one you live in depends on the tier.
WHAT FOR...OF DOES
the protocol per iteration, and what the optimiser makes of it
swipe the figure sideways, or tap expand for full screen
1/6
GetIterator
GetIterator: load arr[Symbol.iterator] (a prototype lookup, cached), call it. For an array this is the ArrayIterator constructor: a small object holding the array, the index 0, and the kind (values). One allocation.
65

Generators: a frame that pauses

A generator function is compiled like any function, with two extra bytecodes at each yield: one that saves the live registers into a generator object and returns, and one that restores them and continues. The generator object is a heap-allocated, resumable frame. Everything else (lazy sequences, coroutines, state machines, async functions) is built from that one capability.

the mechanics
  1. Calling a generator function allocates a JSGeneratorObject: the function, the context, the receiver, the parameters, a register save area sized for the function's register file, the state (suspendedStart), and runs nothing (the body begins with a suspend).
  2. next(v): builtin checks the state (closed → {done: true}; running → throw); builds an interpreter frame; copies the saved registers in; ResumeGenerator delivers v as the value of the yield expression; runs until the next SuspendGenerator, which copies the live registers out, records the resume offset, and returns {value, done: false}.
  3. return(v), throw(e): resume with a return or throw completion at the yield point. try/finally and try/catch in the body behave as if the return or throw happened there. State becomes closed afterwards (unless a finally yields again).
  4. yield*: delegates to an inner iterator: each outer next forwards to the inner, including return and throw. Costs a forwarding call per step; the engine does not flatten nested delegation.
  5. Optimisation: generator bodies can be compiled by Maglev and TurboFan; suspend points are deopt-like safepoints. When a generator and its consumer are inlined together in simple cases, the result objects disappear. In general, expect each step to cost a call, a register copy, and a small allocation.
code
// lazy pipelines: nothing runs until consumed; each stage pulls one item at a time
function* map(it, f) { for (const x of it) yield f(x) }
function* filter(it, p) { for (const x of it) if (p(x)) yield x }
function* take(it, n) { let i = 0; for (const x of it) { if (i++ >= n) return; yield x } }

const firstTenEvens = take(filter(map(naturals(), x => x * 2), x => x % 4 === 0), 10)
// naturals() is infinite; take stops after 10; filter and map ran exactly as many times as needed

// Iterator helpers (ES2025, shipped in V8): the same, built in, with fast paths
naturals().map(x => x * 2).filter(x => x % 4 === 0).take(10).toArray()
// Iterator.prototype.map/filter/take/drop/flatMap/reduce/toArray/forEach/some/every/find; Iterator.from(iterable)
when to use them
  1. Lazy or infinite sequences consumed partially: pagination, search, streams of events.
  2. Pipelines where intermediate arrays would be large (map → filter → take over millions of records): one item in flight at a time.
  3. Coroutines and state machines: a parser that yields tokens, a scheduler that yields between steps, a tween that yields per frame. The suspended frame is the state.
  4. Not for: hot numeric inner loops (the per-step cost is a call plus an allocation); simple collection transforms where map and filter on arrays are clearer and faster for small sizes.
  5. Iterator helpers (Iterator.prototype.map and friends) give the lazy pipeline with built-in implementations and the engine's fast paths; prefer them to hand-written generator combinators where available.
A GENERATOR'S SUSPENDED FRAME
what yield saves and next restores
swipe the figure sideways, or tap expand for full screen
1/6
create
function* range(n) { for (let i = 0; i < n; i++) yield i }. Calling range(3) runs nothing: it allocates a generator object with the function, the current context, a parameter area (n = 3), and state "suspendedStart". The body's bytecode contains SuspendGenerator / ResumeGenerator at each yield.
66

Async iteration

Async iteration is the sync protocol with promises in two places: next() returns a promise of {value, done}, and the consumer awaits it. for await and async generators are the syntax; a microtask hop per step is the minimum cost, and real I/O per step is the usual reason to use them.

the protocol
  1. Async iterable: [Symbol.asyncIterator]() returning an async iterator whose next() returns a promise. If an object has only Symbol.iterator, for await wraps it: each sync value is awaited (so an array of promises works).
  2. for await: per step: await next(), check done, bind value, run body. On early exit: await return().
  3. Async generators: async function*. The object holds a queue of next/return/throw requests, each with its own promise. The body runs one step per request; yield resolves the head request; await inside suspends the body without resolving anything. Requests are resolved strictly in order.
  4. yield* in an async generator delegates to an async iterable, awaiting each step.
costs and shapes
  1. Per item: a promise for next()'s result, a microtask hop to resume the consumer, and for async generators the frame save/restore plus request queue handling. Microseconds, not nanoseconds.
  2. The common mistake: for await over in-memory data (an array of objects) because the body happens to be async. Iterate synchronously and await inside the body instead, or batch with Promise.all if the operations are independent.
  3. Backpressure: async iteration is pull-based; the producer advances one step per request. A Node Readable's async iterator stops reading from the underlying resource when its buffer is full, because nobody asked for more. This is the simplest correct backpressure model in the language.
  4. Concurrency: for await is sequential by construction: one item at a time. For N-at-a-time processing of a stream, pull into a bounded worker pool; the loop alone will not do it.
  5. Cleanup: early exit awaits return(); an async generator's finally runs to completion before the loop proceeds. An infinite async source (a subscription) that the consumer stops reading without breaking out of the loop is never cleaned up.
run it
Time for await (const x of arrayOfNumbers) against for (const x of arrayOfNumbers) for a million elements. The ratio is the microtask hop per item; it is the reason to reserve for await for sources that are actually asynchronous.
ASYNC ITERATION
for await, and what each step waits on
swipe the figure sideways, or tap expand for full screen
1/6
the lookup
for await (const chunk of stream): GetIterator with the async hint: looks up Symbol.asyncIterator first, falls back to Symbol.iterator wrapped so that sync iterables work (each value is awaited). A Node Readable stream has an asyncIterator that yields chunks as they arrive.
67

Iteration forms compared

FormMechanismPer element (Ignition)Per element (TurboFan, array)Notes
for (let i = 0; i < n; i++)Compare, branch, load, increment~4 bytecodes~3 instructionsThe floor. Reads length each iteration unless hoisted (the optimiser hoists it when the array is not modified in the loop)
for (const x of arr)Iterator protocolCall + 2 loads + 1 allocSame as index loop (protector intact)Correct for holes (skips nothing; holes read as undefined); calls return() on break
arr.forEach(fn)Builtin loop; callback call per elementBuiltin + call per elementInlined with the callback when monomorphic and small; close to an index loopSkips holes; cannot break; this arg available
arr.map/filter/reduceBuiltin loop + result constructionAs forEach plus allocation of the resultInlined; result array pre-sized for mapSpecies check (protector); a new array each time
for (const k in obj)Enumeration cache on the map; walks the prototype chain's enumerable keysCheap for fast-mode objects with a warm cacheSameIncludes inherited enumerables; integer keys first in ascending order; slow for dictionary-mode objects; not for arrays
Object.keys(obj).forEachBuilds a keys array from the enumeration cache, then forEachAllocation + loopAllocation + inlined loopOwn enumerable string keys only
for (const [k, v] of map)Map iterator over the ordered hash table's entriesCall + destructuring loadsWalks the backing array directlyInsertion order; tolerant of deletion during iteration
for (const ch of str)String iterator by code pointCall per code pointFast path for one-byte stringsSurrogate pairs yield one value; indexing a string yields UTF-16 units
for await (const x of src)Async protocolPromise + microtask per stepSame (no sync fast path)Only for async sources
Generator for (const x of gen())Frame resume per stepCall + register copy + allocPartially inlined in simple casesLazy; infinite OK; cleanup via return()
Iterator helpers it.map(f).take(n)Built-in lazy adaptersAdapter call per stepFast paths in progressPrefer over hand-written generator combinators
while (i--), reverse loopsSame as index loopSameSame; no faster in modern V8The "reverse loops are faster" lore died with Crankshaft
the pointer
Part 10 is the engine's side of async: how a promise reaction becomes a job, what await compiles to (a generator suspend plus a promise reaction), and the exact microtask ordering rules. The async iteration costs above are those mechanisms counted per item.
68

Reading iteration: tools

ToolShowsUse it for
--print-bytecode: GetIterator, CallProperty0 next, SuspendGenerator, ResumeGenerator, JumpLoopThe protocol per iteration; generator suspend pointsWhat a loop costs in the interpreter
--trace-turbo-inlining: "ArrayIteratorPrototypeNext" and the callbackWhether the iterator protocol and callbacks were inlinedIs this for...of free in this function
%DebugPrint of the Array.prototype[Symbol.iterator] function and protector status via --trace-protector-invalidationWhich protector cells have been invalidated and by what"Why did every array loop in the app get slow after loading library X"
--trace-deopt reason "wrong elements kind" inside a for...ofArray kind instability under an inlined iteratorHoley or mixed arrays entering a hot loop
DevTools Performance: async generator frames and promise reaction framesThe per-step overhead of async iteration as real timeConfirming an async loop over sync data
%DebugPrint(gen)The JSGeneratorObject: state, resume offset, saved registersWhat a suspended generator holds (and retains)
A benchmark in both tiers (--no-opt --no-maglev vs default)The protocol cost versus the inlined costDeciding where an index loop is worth the ugliness
run it
node --trace-protector-invalidation -e "Array.prototype[Symbol.iterator] = function*(){ yield* Array.from(this) }; [1,2].map(x=>x)". The trace line names the protector that just died. Every spread, destructuring and for...of over arrays in that process is now on the slow path, and no profiler will tell you why.