Skip to content

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.

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!

To understand iteration, we only need to look at two simple contracts under the hood:

  1. The Iterable Protocol: Defines how an object starts iterating. It must have a method at [Symbol.iterator] that returns an iterator.
  2. 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]

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,
),
]

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()

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.

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/finally blocks 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 take or break clauses).

The golden rule? Don’t ask, “Can this be a generator?” Ask yourself, “Will my app benefit from processing these values one by one?”

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!