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.

A slitting machine carrying a long stack of disc cutters, a bin heaped with washers below one end and a small tray holding a few at the other, with one brass key wedged into the drive gear.

Two chains over the same million-element array, asking for the same five numbers:

js
// 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  0

Thirteen 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:

js
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:

js
for (const v of src().map(x => x * 2)) { if (v > 4) break; }
// finally on break

This 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:

js
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 open

Add 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.

Arrow keys to move, Enter to open.