JavaScript Iterators and Generators
At its heart, iterables tell JavaScript how to start a loop, and iterators tell it how to get the next value. Generators are just a beautiful, built-in way to create iterators—pausing their work with yield until you ask for more. This lets us handle giant, infinite, or streaming data one item at a time, entirely on demand.
Introduction
Section titled “Introduction”We all love array methods like .map() and .filter(). They make processing data incredibly smooth and readable. But what happens if you’re dealing with hundreds of thousands of records, an infinite stream, or data arriving slowly over the network? Building massive arrays in memory can slow things down and waste precious bytes.
This is where iterators and generators come to the rescue. They let us work with data on demand: instead of storing everything at once, we represent the process of getting the next item. Let’s dive in and see how this works!
The Two Protocols Underneath Iteration
Section titled “The Two Protocols Underneath Iteration”To understand iteration, we only need to look at two simple contracts under the hood:
- The Iterable Protocol: Defines how an object starts iterating. It must have a method at
[Symbol.iterator]that returns an iterator. - The Iterator Protocol: Defines how to get values. It’s an object with a
next()method returning{ value, done: boolean }.
Here is how we can build a simple countdown-iterable from scratch:
const countdown: Iterable<number> = { [Symbol.iterator]() { let current = 3
return { next(): IteratorResult<number> { if (current === 0) return { done: true, value: undefined } return { done: false, value: current-- } }, } },}
console.log([...countdown]) // [3, 2, 1]This protocol powers all the familiar things we love—like the for...of statement, array destructuring, spread operator (...), and even the Set constructor!
One neat detail: some iterables can be restarted (like our countdown above), while built-in iterators (like values()) are “one-shot” because they track their current state.
const iterator = ['a', 'b', 'c'].values()
console.log(iterator.next()) // { value: 'a', done: false }console.log([...iterator]) // ['b', 'c']console.log([...iterator]) // [] (already consumed!)Generators: Iterators Without the Bookkeeping
Section titled “Generators: Iterators Without the Bookkeeping”Managing iteration state manually can get tedious. Thankfully, we have generator functions (declared with function*). When you call a generator, it doesn’t execute its body immediately. Instead, it returns a generator object that implements both protocols for us!
Calling .next() runs the code until it hits a yield statement. Then, it pauses the function, emits the value, and waits:
function* countdownFrom(start: number): Generator<number> { for (let current = start; current > 0; current--) { yield current }}
const numbers = countdownFrom(3)console.log(numbers.next()) // { value: 3, done: false }console.log(numbers.next()) // { value: 2, done: false }What makes this magical is that local variables and the execution state are completely preserved between pauses.
If you ever need to delegate iteration to another iterable (or another generator), use yield*. It’s incredibly elegant for tree traversal and recursive structures without building intermediate arrays:
type Branch = number | Branch[]
function* flatten(branch: Branch): Generator<number> { if (typeof branch === 'number') { yield branch return }
for (const child of branch) yield* flatten(child)}
console.log([...flatten([1, [2, [3]]])]) // [1, 2, 3]Practical Use Case 1: Lazy Transformation
Section titled “Practical Use Case 1: Lazy Transformation”Imagine you need to grab just the first three active records from a massive dataset. If you use standard array methods, you have to iterate and allocate memory for every single record first, even if you only need three!
const visible = records .filter(record => record.active) .map(record => record.name.toUpperCase()) .slice(0, 3) // Eagerly processes the entire array!With generators, we can build a lazy pipeline that does only the bare minimum of work:
function* filter<T>(source: Iterable<T>, keep: (value: T) => boolean) { for (const value of source) if (keep(value)) yield value}
function* map<T, U>(source: Iterable<T>, transform: (value: T) => U) { for (const value of source) yield transform(value)}
function* take<T>(source: Iterable<T>, count: number) { if (count <= 0) return for (const value of source) { yield value if (--count === 0) return }}
const visible = [ ...take( map( filter(records, record => record.active), record => record.name.toUpperCase(), ), 3, ),]Iterator methods
Baseline2025newly availableSupported in Chrome: yes.
Supported in Edge: yes.
Supported in Firefox: yes.
Supported in Safari: yes.
Since March 2025 this feature works across the latest devices and browser versions. This feature might not work in older devices or browsers.
Even better, modern JS features native Iterator helper methods! They allow you to chain lazy filtering, mapping, and taking out of the box:
const visible = Iterator.from(records) .filter(record => record.active) .map(record => record.name.toUpperCase()) .take(3) .toArray()Asynchronous Iteration: Paginated APIs
Section titled “Asynchronous Iteration: Paginated APIs”What if our data doesn’t arrive all at once? The asynchronous iterator protocol lets us iterate over async data as it arrives. By combining await and yield inside an async function*, we can create asynchronous streams.
Fetching pages from an API to process them sequentially is a perfect candidate. We can pull a page, yield its items, and only fetch the next page if the consumer asks for more! We consumption-loop these using for await...of:
interface Page<T> { items: T[] nextUrl: string | null}
async function* paginate<T>(firstUrl: string): AsyncGenerator<T> { let url: string | null = firstUrl
while (url !== null) { const response: Response = await fetch(url) if (!response.ok) throw new Error(`Request failed: ${response.status}`)
const page: Page<T> = await response.json() yield* page.items url = page.nextUrl }}
for await (const account of paginate<Account>('/api/accounts')) { await indexAccount(account) if (indexIsFull()) break // No more pages are fetched unnecessarily!}This gives us natural backpressure: we don’t load and fetch data until our code is actively ready to process it.
Trade-Offs and Best Practices
Section titled “Trade-Offs and Best Practices”As beautiful as iterators are, they aren’t always the answer. Standard arrays are still best when you need index-based random access, repeated traversals, or when you just need the entire collection in memory immediately.
Here is a quick cheat sheet when working with iteration:
- Wrap when reusable: Prefer returning an iterable (which creates a new iterator) if the data needs to be traversed more than once.
- Watch out for side effects: Since evaluation is lazy, delaying execution can make debugging tricky if your mappings change external state.
- Always clean up: Wrap resource-heavy code in
try/finallyblocks so your generators cleanly release resources when loops break early. - Bound your infinite loops: When dealing with potentially infinite generators, always use safety limits (like
takeorbreakclauses).
The golden rule? Don’t ask, “Can this be a generator?” Ask yourself, “Will my app benefit from processing these values one by one?”
Conclusion
Section titled “Conclusion”JavaScript iteration is a small set of contracts with a massive impact. From basic loops to streaming bytes over the network, Symbol.iterator and generator functions make handling sequences a joy to write. Use them deliberately, clean up your resources, and write clean, lazy code!
References
Section titled “References”- Iteration protocols — MDN
Symbol.iterator— MDNfor...of— MDN- Generator functions — MDN
yield— MDNyield*— MDN- Asynchronous generator functions — MDN
for await...of— MDN- Readable stream asynchronous iteration — MDN
- Stop turning everything into arrays (and do less work instead) — Matt Smith
- I think the ergonomics of generators is growing on me — Alex MacArthur