JAVASCRIPT / DESTRUCTURING, ITERABLES, AND GENERATORS
Choosing generators against arrays for big streams
Decide when a large sequence should be an array or a lazy generator, and know exactly what you give up: length, indexing, and a second pass.
What you will learn
- Estimate work per stage: array chains touch every item, generators only pulled ones
- Rewrite map/filter stages as generators so no intermediate array is allocated
- Spot the one-shot trap: a generator object is empty after its first full pass
- Materialize with [...] only where you need sorting, length, or random access
Understanding Choosing generators against arrays for big streams
An array is a container of values that already exist; a generator is a paused function that produces a value when something asks for one. That difference shows up the moment a pipeline has several stages: nums.map(square).filter(test).slice(0, 3) over a million numbers allocates a second million-element array, then a third, calls square a million times, and discards almost all of that work. The generator version of the same three stages calls square nine times and allocates nothing larger than the three-element result, because each number travels through square, the filter and the consumer before the next number is created.
The mental model worth carrying is two numbers: how many values are alive at once, and how many values the consumer actually needs. An array pipeline pins the first at N per stage and the second at N, no matter what the consumer does with the result. A generator pipeline pins the first at one value per stage and lets the consumer decide the second, which is why break, find and some become cheap, and why a sequence with no end (log lines still being written, pages of an API you have not requested yet) can be expressed at all.
The price is per-item overhead and lost capabilities. Every step through a generator chain is a function resume plus a fresh { value, done } object, so for a few hundred items an array chain is faster and much easier to inspect in a debugger, since you can log the whole thing. You also give up length, items[i], sort, and reuse: a generator object is consumed once and is then permanently done. Choose a generator when the data is large, unbounded, or arriving from IO and you read it once in order; choose an array when it is small, needs sorting or random access, or gets walked more than once.
const N = 1_000_000;
let arraySquarings = 0;
let genSquarings = 0;
function firstThreeWithArrays() {
return Array.from({ length: N }, (_, i) => i)
.map(n => { arraySquarings++; return n * n; })
.filter(sq => sq % 7 === 1)
.slice(0, 3);
}
function* count(limit) {
for (let i = 0; i < limit; i++) yield i;
}
function* square(nums) {
for (const n of nums) {
genSquarings++;
yield n * n;
}
}
function* where(values, test) {
for (const v of values) if (test(v)) yield v;
}
function firstThree(values) {
const out = [];
for (const v of values) {
out.push(v);
if (out.length === 3) break;
}
return out;
}
const lazy = firstThree(where(square(count(N)), sq => sq % 7 === 1));
console.log('arrays: ', firstThreeWithArrays().join(', '), 'after', arraySquarings, 'squarings');
console.log('generators:', lazy.join(', '), 'after', genSquarings, 'squarings');For a large or unbounded sequence a generator buys constant memory and work proportional to what the consumer pulls, paid for with length, indexing, and the ability to iterate again.
Worked examples
What a generator cannot answer
Shows the two capabilities you lose when you pick a generator over an array: a second pass and direct access.
function* twoLines() {
yield 'first';
yield 'second';
}
const stream = twoLines();
console.log([...stream].length);
console.log([...stream].length);
const rows = ['first', 'second'];
console.log(rows.length, rows[1]);
console.log(twoLines().length, twoLines()[1]);Example explained
Line 1The first spread drains the generator object completely, so it reports 2.
Line 2The second spread finds that same object already done and builds an empty array: 0, with no error to warn you.
Line 3The array answers rows.length and rows[1] instantly because its values are stored, not computed.
Line 4A generator object has no length and no index properties, so both reads are undefined, which quietly breaks any if (stream.length) check.
Constant memory over two million values
Compares streaming an aggregate against materializing the same sequence first.
function* naturals(limit) {
for (let i = 0; i < limit; i++) yield i;
}
function average(values) {
let sum = 0;
let count = 0;
for (const v of values) {
sum += v;
count++;
}
return sum / count;
}
console.log(average(naturals(2_000_000)));
const materialized = [...naturals(2_000_000)];
console.log(materialized.length, average(materialized));Example explained
Line 1average() never receives an array; it pulls one number at a time, so its memory is two variables no matter how large limit is.
Line 2The spread asks for every value up front and holds 2,000,000 of them at once before averaging even starts.
Line 3The results are identical: laziness changes peak memory and when work happens, not the answer.
Line 4Raise limit far enough and the spread runs out of memory while average(naturals(limit)) only takes longer.
When the array is the right choice
Separates a one-pass reduction, where a generator wins, from sorting and counting, which force materialization.
function* readWords() {
yield 'pear';
yield 'fig';
yield 'apple';
yield 'plum';
}
let longest = '';
for (const w of readWords()) {
if (w.length > longest.length) longest = w;
}
console.log(longest);
const words = [...readWords()];
words.sort();
console.log(words.join(' '), words.length, words[0]);Example explained
Line 1The longest-word scan keeps a single candidate alive, so storing the values would buy nothing.
Line 2sort cannot produce its first result until the last value has arrived, so sorted output always needs the whole sequence in memory.
Line 3The spread is the deliberate point where you accept that cost; doing it once and reusing words avoids re-running the source.
Line 4After materializing, length and words[0] work again because the values now exist as stored elements.
Important notes
Recent runtimes (Chrome 122+, Node 22+, Firefox 131+, Safari 18.4+) add iterator helpers such as .map, .filter, .take and .toArray directly on iterators, so hand-written stage generators are often unnecessary there; check support before depending on them.
Lazy stages cannot fix an eager source: if the generator wraps an already-loaded array or a fully parsed JSON body, peak memory is already spent, so the source itself must stream (line-by-line reads, paginated requests).
Common mistakes
Finishing a lazy pipeline with [...pipeline] or Array.from immediately: peak memory equals the array version and every item now also pays iterator overhead, so the rewrite is purely slower.
Iterating the same generator object twice, for example counting it and then looping to print it: the second pass yields nothing and you get 0 or an empty result with no error raised.
Treating a generator object like an array with .length, [i] or .sort: you get undefined or a TypeError, and a length check silently takes the wrong branch.
Try it yourself
Change, predict, then run
In a browser editor, write a generator that yields 300000 log lines of the form 'id=<i> level=info', using level=error on every 50000th line, then find the first three error lines twice, once with [...lines()].filter(...).slice(0, 3) and once with generator stages plus a for...of that breaks. Have each version increment a counter for every line it inspects and log both counters.
Open the JavaScript workspaceCheck your understanding
You replace an array method chain over a million items with a generator pipeline, then finish it with [...pipeline].length. Peak memory is unchanged and the code is slightly slower than before. What explains that?
- The pipeline stages each still build an intermediate array, just later than they used to.
- Spreading pulls every value into one new array, so peak memory is unchanged and each value now also pays for an iterator step.
- Generator functions cannot be optimized by the JIT, so a generator pipeline is always slower than array methods.
- Reading .length restarts the generator from the beginning, so all the work happens twice.
Show answer
Laziness only pays off when the consumer stops early or never holds all the values at once; a spread does the opposite, so you keep the full allocation and add per-item resume and { value, done } cost on top. Option 1 is tempting because it sounds like the same intermediate-array problem, but generator stages allocate no arrays at all here, the single array comes from the spread.