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.
The iterator protocol, and what for...of really does
"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.
// 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)- 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. - Iterator: an object with
next()returning{value, done}; optionallyreturn()(called on early exit) andthrow(). - The loop:
GetIteratoronce; per step:next(), readdone, readvalue, body; onbreak,throworreturnfrom inside the body, callreturn()if it exists, then propagate. - Destructuring, spread,
Array.from,Promise.all,new Map(...): all consumers of the same protocol, all with the same fast paths.
- Protector cells: global boolean cells that record "nobody has modified X":
Array.prototype[Symbol.iterator],%ArrayIteratorPrototype%.next,Array.prototypehaving no indexed elements,Symbol.specieson 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. - Array iteration in TurboFan: protector valid + stable elements kind →
next()inlined to an index increment and bounds check;{value, done}eliminated by escape analysis;valueloaded directly from the elements. Identical to an index loop. - Spread and
Array.fromon arrays: a memcpy-like fast path under the same protector. - Maps and Sets: their iterators are also recognised; iteration over a Map in optimised code walks the hash table's entries array directly.
- Strings: iterate by code point (not UTF-16 unit); a fast path for one-byte strings.
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.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.
- 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).
next(v): builtin checks the state (closed →{done: true}; running → throw); builds an interpreter frame; copies the saved registers in;ResumeGeneratordeliversvas the value of theyieldexpression; runs until the nextSuspendGenerator, which copies the live registers out, records the resume offset, and returns{value, done: false}.return(v),throw(e): resume with a return or throw completion at the yield point.try/finallyandtry/catchin the body behave as if the return or throw happened there. State becomes closed afterwards (unless a finally yields again).yield*: delegates to an inner iterator: each outernextforwards to the inner, includingreturnandthrow. Costs a forwarding call per step; the engine does not flatten nested delegation.- 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.
// 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)- Lazy or infinite sequences consumed partially: pagination, search, streams of events.
- Pipelines where intermediate arrays would be large (map → filter → take over millions of records): one item in flight at a time.
- 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.
- Not for: hot numeric inner loops (the per-step cost is a call plus an allocation); simple collection transforms where
mapandfilteron arrays are clearer and faster for small sizes. - Iterator helpers (
Iterator.prototype.mapand friends) give the lazy pipeline with built-in implementations and the engine's fast paths; prefer them to hand-written generator combinators where available.
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.
- Async iterable:
[Symbol.asyncIterator]()returning an async iterator whosenext()returns a promise. If an object has onlySymbol.iterator,for awaitwraps it: each sync value is awaited (so an array of promises works). for await: per step:await next(), checkdone, bindvalue, run body. On early exit:await return().- Async generators:
async function*. The object holds a queue ofnext/return/throwrequests, each with its own promise. The body runs one step per request;yieldresolves the head request;awaitinside suspends the body without resolving anything. Requests are resolved strictly in order. yield*in an async generator delegates to an async iterable, awaiting each step.
- 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. - The common mistake:
for awaitover in-memory data (an array of objects) because the body happens to be async. Iterate synchronously and await inside the body instead, or batch withPromise.allif the operations are independent. - 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.
- Concurrency:
for awaitis 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. - Cleanup: early exit awaits
return(); an async generator'sfinallyruns 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.
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.Iteration forms compared
| Form | Mechanism | Per element (Ignition) | Per element (TurboFan, array) | Notes |
|---|---|---|---|---|
for (let i = 0; i < n; i++) | Compare, branch, load, increment | ~4 bytecodes | ~3 instructions | The 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 protocol | Call + 2 loads + 1 alloc | Same 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 element | Builtin + call per element | Inlined with the callback when monomorphic and small; close to an index loop | Skips holes; cannot break; this arg available |
arr.map/filter/reduce | Builtin loop + result construction | As forEach plus allocation of the result | Inlined; result array pre-sized for map | Species check (protector); a new array each time |
for (const k in obj) | Enumeration cache on the map; walks the prototype chain's enumerable keys | Cheap for fast-mode objects with a warm cache | Same | Includes inherited enumerables; integer keys first in ascending order; slow for dictionary-mode objects; not for arrays |
Object.keys(obj).forEach | Builds a keys array from the enumeration cache, then forEach | Allocation + loop | Allocation + inlined loop | Own enumerable string keys only |
for (const [k, v] of map) | Map iterator over the ordered hash table's entries | Call + destructuring loads | Walks the backing array directly | Insertion order; tolerant of deletion during iteration |
for (const ch of str) | String iterator by code point | Call per code point | Fast path for one-byte strings | Surrogate pairs yield one value; indexing a string yields UTF-16 units |
for await (const x of src) | Async protocol | Promise + microtask per step | Same (no sync fast path) | Only for async sources |
Generator for (const x of gen()) | Frame resume per step | Call + register copy + alloc | Partially inlined in simple cases | Lazy; infinite OK; cleanup via return() |
Iterator helpers it.map(f).take(n) | Built-in lazy adapters | Adapter call per step | Fast paths in progress | Prefer over hand-written generator combinators |
while (i--), reverse loops | Same as index loop | Same | Same; no faster in modern V8 | The "reverse loops are faster" lore died with Crankshaft |
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.Reading iteration: tools
| Tool | Shows | Use it for |
|---|---|---|
--print-bytecode: GetIterator, CallProperty0 next, SuspendGenerator, ResumeGenerator, JumpLoop | The protocol per iteration; generator suspend points | What a loop costs in the interpreter |
--trace-turbo-inlining: "ArrayIteratorPrototypeNext" and the callback | Whether the iterator protocol and callbacks were inlined | Is this for...of free in this function |
%DebugPrint of the Array.prototype[Symbol.iterator] function and protector status via --trace-protector-invalidation | Which 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...of | Array kind instability under an inlined iterator | Holey or mixed arrays entering a hot loop |
| DevTools Performance: async generator frames and promise reaction frames | The per-step overhead of async iteration as real time | Confirming an async loop over sync data |
%DebugPrint(gen) | The JSGeneratorObject: state, resume offset, saved registers | What a suspended generator holds (and retains) |
A benchmark in both tiers (--no-opt --no-maglev vs default) | The protocol cost versus the inlined cost | Deciding where an index loop is worth the ugliness |
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.