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

- Published: 2026-10-25
- Tags: javascript, nodejs
- Source: https://jsledger.com/blog/iterator-helpers-are-lazy/
- Language: en-US
- Author: Jonah Vail

---
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](/blog/how-v8-decides-to-optimize-your-function/), 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](/blog/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.
