Language internals
Your Array Chain Called the Mapper a Million Times
Two chains return the same five numbers. One calls the mapper 1,000,000 times and allocates 13.73 MB; the other calls it 13 times and allocates nothing measurable.

Two chains over the same million-element array, asking for the same five numbers:
// Node 26.8.1, V8 14.6.202.34-node.28
const viaArray = src.map(x => { calls++; return x * 2 })
.filter(x => x % 3 === 0)
.slice(0, 5);
const viaIter = src.values()
.map(x => { calls++; return x * 2 })
.filter(x => x % 3 === 0)
.take(5)
.toArray();Both return 0,6,12,18,24. The first ran the mapper 1,000,000 times; the
second ran it 13 times.
slice is the last thing that happens, take is the first
The array version has no way to be cheaper. map returns an array, so it has to
finish; filter returns an array, so it has to finish too; slice is handed a
completed 333,334-element result and throws away all but five. Each method is a
loop that runs to the end before the next one starts.
The iterator version inverts the direction. toArray asks take for a value,
take asks filter, filter asks map, map asks the array iterator. One
value moves through the whole chain, then the next. When take has handed over
five values it reports done, and nothing upstream is asked again.
Thirteen is not a magic number; it is the count of pulls needed to reach five survivors. The mapper doubles, the filter keeps multiples of three, so the survivors come from indices 0, 3, 6, 9 and 12 — thirteen values pulled, five kept.
What the intermediate arrays cost
Function calls are the visible difference. The allocations are the expensive one:
$ node alloc.mjs <mode> # x5, 1,000,000 items, same five results
array heapUsed delta 13.73 MB elapsed 12.61 12.28 12.45 12.10 12.05 ms
iterator heapUsed delta 0.01 MB elapsed 0.05 0.05 0.06 0.05 0.05 ms
$ node --trace-gc alloc.mjs <mode> | grep -c Scavenge
array 4
iterator 0Thirteen megabytes to produce five numbers, and four young-generation collections
to clean it up. The iterator chain does not allocate an intermediate array
because there is no intermediate anything — the value that leaves map is
consumed by filter before the next one is produced.
This is the part worth carrying to other code. The cost of an array pipeline is not the extra loops; V8 is good at loops. It is that every stage materializes a result the next stage immediately walks and discards, and materializing is what the garbage collector has to notice.
take sends something back up the chain
The direction reverses again at the end. When take(5) is satisfied it does not
only stop pulling; it calls return() on its source, which calls return() on
its source, all the way to the bottom. For a generator that means the finally
block runs:
function* source() {
try {
let i = 0;
while (true) { yield i++; }
} finally {
console.log('finally ran');
}
}
source().map(x => x * 10).filter(x => x > 0).take(2).toArray();
// finally ran
// took [ 10, 20 ]An infinite generator, drained twice, closed cleanly. The same propagation
happens on a break, through the same helpers:
for (const v of src().map(x => x * 2)) { if (v > 4) break; }
// finally on breakThis is the closest thing iterator helpers have to a resource-management story, and it is the reason a generator-backed pipeline can own a file handle or a cursor at all.
Where it stops working
The chain forwards return(). It cannot invent one. A hand-written iterator that
holds something open and does not implement return() receives nothing:
let open = 0;
function handle() {
open++;
let i = 0;
return { next: () => ({ value: i++, done: false }),
[Symbol.iterator]() { return this } }; // no return()
}
Iterator.from(handle()).map(x => x).take(2).toArray();
console.log(open); // 1 — still openAdd a return() that decrements and it fires, once, at the moment take is
satisfied. The helper itself always has one; Iterator.from(src).map(f).return
is a function. The break in the chain is at the bottom, in the object you
wrote.
Generators get this for free, which is a good argument for writing sources as
generators rather than as object literals with a next. If you do write the
object literal, the return() method is not optional decoration; it is the only
notification you will get that nobody wants your values any more.
The case where the array chain wins
An article that only confirms its thesis is a sales page, so here is the measurement that goes the other way. Remove the early exit — consume every result instead of the first five — and the ranking inverts:
$ node full.mjs <mode> # 1,000,000 items, no take/slice, medians of 7
array n = 333334 median 10.8 ms (10.1 10.6 10.8 10.8 11.1 11.7 11.9)
iterator n = 333334 median 20.5 ms (19.7 19.8 20.5 20.5 22.4 22.8 23.2)About twice as slow, for identical output. What I can say from the code is that
every value crosses three iterator protocol boundaries and produces a result
object on the way, where an array method is a plain loop over a contiguous
backing store. Why that costs a factor of two rather than a factor of ten is a
question about how well each shape tiers
up, and I have not put either
version under --trace-opt to find out.
So the rule is not “iterator helpers are faster”. It is that laziness is worth exactly as much as the work it lets you skip. Take a prefix, search for the first match, walk something unbounded — the helpers are the only option that finishes. Consume the whole thing into an array and you have paid protocol overhead for a property you did not use.
What to do with this
Reach for .values() at the point where a chain ends in slice, find,
some, or a loop with a break — those are the shapes where the array version
is doing work it will discard. Keep array methods where the pipeline’s output is
the array.
The counting harness above is four lines and worth keeping: increment inside the
mapper, print the count, and the laziness stops being a claim about the language
and becomes a number about your code. That is the same move as
watching what await compiles to rather than
reasoning about the ordering, and it works here for the same reason — the
behavior is observable, so observe it.
What I did not test: async iterator helpers, which are a separate proposal with their own scheduling; and whether the two-to-one gap above narrows on chains long enough for the intermediate arrays to stop fitting in the young generation. Everything here ran on Node 26.8.1; the helpers are also present on Node 24.20.0, where the same script reports the same 1,000,000 against 13.
FAQ
Frequently asked
Are iterator helpers always faster than array methods?
No. They win when the consumer stops early. Measured on Node 26.8.1 over 1,000,000 items with no early exit, the array chain finished in a median 10.8 ms and the iterator chain in 20.5 ms — the array version is about twice as fast when every element is consumed anyway.
Does breaking out of a for...of loop close the underlying generator?
Yes, and it still does through a helper chain. A break calls return() on the iterator, each helper forwards it to its source, and the generator runs its finally block. A hand-written iterator that has no return() method receives nothing, so any cleanup it owns does not happen.


