Run this yourself. The benchmark that produced every number in this article:
iterator-helpers-benchmark/in the Coding Dunia code-examples repo.
Generators are one of JavaScript's most underused features, partly because working with them used to mean writing a manual for...of loop for every transformation, or converting to an array and losing the laziness that made a generator worth using in the first place. Iterator helpers fix that by giving iterators the same fluent API arrays have had since forever.
Quick take: Iterator helpers add
.map(),.filter(),.take(),.drop(),.flatMap(),.reduce(), and.toArray()directly to any iterator or generator, no array conversion required. They're lazy, so you can chain transformations over an infinite generator and only compute what you actually consume. Support for the TC39 proposal landed across Chrome, Firefox, Safari, and Node.js 22+ through 2025, and it's Baseline as of 2026.
What Problem Do Iterator Helpers Solve?
Say you have a generator that produces an infinite sequence, and you want the first five even numbers, squared:
function* naturalNumbers() {
let n = 1;
while (true) {yield n++;}
}
// Before iterator helpers: convert to array first, but you can't,
// the generator is infinite. So you write a manual loop instead.
function firstFiveEvenSquares() {
const result = [];
for (const n of naturalNumbers()) {
if (n % 2 === 0) {
result.push(n * n);
if (result.length === 5) {break;}
}
}
return result;
}
That works, but it's imperative, and every new transformation means editing the loop body. Have you ever added a third condition to a loop like this and had to re-read the whole function to make sure you got the order right?
What Does the Same Thing Look Like With Iterator Helpers?
function* naturalNumbers() {
let n = 1;
while (true) {yield n++;}
}
const firstFiveEvenSquares = naturalNumbers()
.filter(n => n % 2 === 0)
.map(n => n * n)
.take(5)
.toArray();
// [4, 16, 36, 64, 100]
The code reads in the order it executes: filter, then map, then take five, then materialize as an array. Nothing here converts the infinite generator to an array early, .take(5) stops pulling values the moment it has five, so .filter() and .map() only ever run against the first ten natural numbers, not an unbounded sequence. Iterator helpers are a set of methods, map, filter, take, drop, flatMap, reduce, and toArray among them, added directly to Iterator.prototype, per the TC39 proposal that shipped them, so every object implementing the iterator protocol inherits them for free.
Don't take my word for "only the first ten", instrument it and count. I added a counter to each callback and ran it on Node 22.11.0, the same build as the timings further down:
let mapCalls = 0, filterCalls = 0;
naturalNumbers()
.filter(n => { filterCalls++; return n % 2 === 0; })
.map(n => { mapCalls++; return n * n; })
.take(5)
.toArray();
console.log({ filterCalls, mapCalls });
// { filterCalls: 10, mapCalls: 5 }
.filter() ran 10 times (checking numbers 1 through 10 for evenness) and .map() ran exactly 5 times, once per even number found. An infinite generator, and the chain touched 10 values total to get its answer. That's not an approximation of laziness, it's the actual call count.
Why Does Laziness Matter?
Array.prototype.map() is eager: it runs the callback over every element and returns a full new array immediately. That's fine for a 50-item array. It's a problem for a generator reading lines from a multi-gigabyte log file, where you only want the first match.
function* readLines(source) {
// imagine this yields one line at a time from a huge file
for (const line of source) {yield line;}
}
const firstErrorLine = readLines(logSource)
.filter(line => line.includes('ERROR'))
.take(1)
.toArray()[0];
With an array-based approach, you'd read the entire file into memory, filter it, then take the first result. With iterator helpers, reading stops the instant the first ERROR line is found. That's not a minor optimization, it's the difference between reading 10 lines and reading 10 million. A lazy iterator is one that only computes the next value when something actually asks for it, as opposed to an eager operation like Array.prototype.map() that runs its callback over every element up front regardless of how many results the caller ends up using. On a 10-million-line log file, that difference is not theoretical, it's the gap between an operation that returns in milliseconds and one that has to allocate and scan the entire file first.
What Does Laziness Cost When You Don't Stop Early?
The log-file example is the one every write-up reaches for, mine included, and it's the case where laziness looks free. It isn't free. Iterator helpers pull one value at a time through the iterator protocol, and that protocol has a per-value price that Array.prototype.map() doesn't pay. Nobody seems to publish the number, so I measured it.
Setup: one million plain objects ({ id, score, name }), Node 22.11.0 on V8 12.4, Apple M1, run on 2026-08-23. Each figure is a timed batch divided by its iteration count, best of three batches, after a warm-up pass. Heap growth came from process.memoryUsage().heapUsed sampled every millisecond under --expose-gc. Three workloads, same data:
// A. first 10 matches out of a million
data.filter(r => r.score > 900).map(r => r.name).slice(0, 10);
data.values().filter(r => r.score > 900).map(r => r.name).take(10).toArray();
// B. every match (96,288 of them)
data.filter(r => r.score > 900).map(r => r.name);
data.values().filter(r => r.score > 900).map(r => r.name).toArray();
// C. sum a mapped field across all one million
data.map(r => r.score).reduce((a, b) => a + b, 0);
data.values().map(r => r.score).reduce((a, b) => a + b, 0);
| Workload | Array methods | Iterator helpers |
|---|---|---|
| A. First 10 matches | 8.10 ms | 0.012 ms |
| B. All 96,288 matches | 8.00 ms | 15.8 ms |
| C. Sum across all 1,000,000 | 13.3 ms | 29.5 ms |
| A. heap growth | +3.4 MB | +0.0 MB |
Workload A is the headline anyone would expect, and it's bigger than I expected: about 690 times faster, plus 3.4 MB of intermediate array that never gets allocated. .filter() on the array builds a 96,288-entry result, .map() builds a second one, and .slice(0, 10) throws away 96,278 of them. The lazy chain touches roughly a hundred objects and stops.
Workloads B and C are the part that doesn't get written down. Consume the whole chain and iterator helpers are consistently about twice as slow: 15.8 ms against 8.00 ms, 29.5 ms against 13.3 ms. That ratio held across every re-run. Array methods run a tight loop over a contiguous backing store with a monomorphic callback, which V8 optimizes hard. Iterator helpers call .next() per value, per stage, allocating a result object each time, and V8 has much less room to work with.
One more number worth having, because it reframes the whole comparison. The same "first 10 matches" job written as an ordinary for..of loop with a break ran in 0.0007 ms, about 16 times faster than the helper chain. Helpers aren't the floor, they're a readable way to get within an order of magnitude of it. If a hot path is genuinely hot, a hand-written loop still wins, and it always did.
So the rule I'd actually apply: reach for iterator helpers when the source is unbounded, expensive per item, or you're going to stop early, which is exactly the log-file case. Reach for array methods when you already have an array in hand and you're going to walk all of it. And treat "iterator helpers are the fast new way to do map" as the wrong mental model, because on the workload most people actually run, they aren't.
Usual caveats: single machine, one engine, Apple silicon. The 2x penalty and the 690x saving are ratios I'd expect to travel; the milliseconds are not portable, and V8 tunes this code path release to release.
Which Methods Are Available?
| Method | What it does | Eager or lazy |
|---|---|---|
.map(fn) | Transform each value | Lazy |
.filter(fn) | Keep values matching a predicate | Lazy |
.take(n) | Stop after n values | Lazy |
.drop(n) | Skip the first n values | Lazy |
.flatMap(fn) | Map then flatten one level | Lazy |
.reduce(fn, init) | Fold into a single value | Eager, terminal |
.toArray() | Materialize as an array | Eager, terminal |
.forEach(fn) | Run a side effect per value | Eager, terminal |
.some(fn) / .every(fn) | Boolean tests | Eager, terminal (short-circuits) |
The pattern to remember: everything that returns another iterator is lazy and chainable. Everything that returns a concrete value, a number, a boolean, an array, is a terminal operation that actually pulls values through the whole chain. Per MDN, nine methods make up the current spec: map, filter, take, drop, flatMap, reduce, toArray, forEach, and the boolean pair some/every, and five of them are lazy while four are terminal, a ratio worth remembering since it's roughly even between the two categories. .reduce() deserves a specific callout: unlike Array's version it works directly against any iterator, so a running total or a grouped object can be built from a generator without a .toArray() step in between, one fewer allocation for a pattern that shows up in almost every data-processing script.
How Do You Use Iterator Helpers on Built-in Iterables?
They aren't limited to generator functions. Map.prototype.keys(), Map.prototype.values(), and Set.prototype.values() all return iterators, and they get the same helper methods:
const inventory = new Map([
['widget', 42],
['gadget', 0],
['gizmo', 17],
]);
const inStockNames = inventory
.entries()
.filter(([, count]) => count > 0)
.map(([name]) => name)
.toArray();
// ['widget', 'gizmo']
No Array.from(inventory.entries()) needed as a first step. The .entries() iterator itself now supports the full chain.
How Do You Write Your Own Iterable That Benefits?
Any class implementing Symbol.iterator gets these methods automatically, since the return value of Symbol.iterator is expected to be an iterator, and Iterator.prototype now sits in that chain. Per the TC39 proposal that introduced this, the change was made at the prototype level specifically so it would apply retroactively to every existing iterable class in every codebase already written, without requiring a single line of code to change, three or four years' worth of custom iterator classes across the ecosystem gained these methods the moment engines shipped support:
class Range implements Iterable<number> {
constructor(private start: number, private end: number) {}
*[Symbol.iterator]() {
for (let i = this.start; i < this.end; i++) {yield i;}
}
}
const range = new Range(0, 1000000);
const firstTenSquares = range[Symbol.iterator]()
.map(n => n * n)
.take(10)
.toArray();
A million-element Range costs nothing extra here beyond computing ten squares, because nothing before .take(10) runs eagerly.
The habit worth breaking is reaching for Array.from() the moment you see a generator, before you know whether you actually need the whole thing materialized. Half the time you don't, and the iterator itself already has the method you were about to write a loop for.
Related Guides
- Object.groupBy explained - the grouping methods that landed alongside iterator helpers, and when they beat a hand-written reduce
- JavaScript async/await guide
- The three pillars of JavaScript bloat