Mert Özen Further With Every Line
Frontend JavaScript Advanced — #07

JavaScript Async Iterators: for await...of, Async Generators and Array.fromAsync

Mert Özen Nov 3, 2026 9 min 5 views
JavaScript Async Iterators: for await...of, Async Generators and Array.fromAsync

We walk through data that arrives over time with Symbol.asyncIterator and async generators, learn for await...of and ES2026's Array.fromAsync, read a paginated API as a stream and meet ReadableStream.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Let's take the generators we saw earlier, put an async in front of one, and try to walk through the result with the loop we already know. What does this code print to the console?

async function* numbers() {
  yield 1;
  yield 2;
}

console.log(numbers().next() instanceof Promise);

for (const n of numbers()) {
  console.log(n);
}

Make a guess before you keep reading. A small hint: the answers to the two parts are connected. We'll get to the answer once we've built up the async iterator protocol.

Where Does a Sync Iterator Get Stuck?

Remember the iterator protocol we saw earlier: you call next() and immediately get back an object shaped like { value, done }. The key word is "immediately". for...of calls next() on every turn and expects the answer to be in hand right then. For an array, a Set or a generator working on data already in memory, that's no problem at all.

But what if the next value doesn't exist yet? The second page of an API, a chunk of a file that hasn't downloaded, the next message from a server... A sync iterator can't wait for any of these; next() has to return something right now. Even if it returns a promise, for...of won't unwrap it; it just hands you the promise itself as the value.

Think of it as a restaurant. A sync iterator is a buffet: you hold out your plate, the food is already on the counter, you take it. An async iterator is table service. You say "next dish, please", the waiter says "coming right up" and heads to the kitchen. You wait at your table, but the rest of the restaurant keeps going. In JavaScript, the word the waiter gave you is called a promise.

The Async Iterator Protocol and Symbol.asyncIterator

Async iteration, which arrived in ES2018, is almost the same as the sync protocol. The only difference is that next() returns a promise, and that promise resolves to the { value, done } object. For an object to count as async iterable, it needs a method under the Symbol.asyncIterator key (instead of Symbol.iterator) that returns such an iterator.

Let's see it with a small hand-written countdown. Paste this into your browser's console and try it:

const countdown = {
  [Symbol.asyncIterator]() {
    let left = 3;
    return {
      next() {
        return new Promise((resolve) => {
          setTimeout(() => {
            if (left === 0) {
              resolve({ value: undefined, done: true });
            } else {
              resolve({ value: left--, done: false });
            }
          }, 500);
        });
      }
    };
  }
};

for await (const n of countdown) {
  console.log(n);
}
console.log("Liftoff!");

You'll see 3, 2, 1 half a second apart, and finally Liftoff!. Here's what the engine does: for await...of first gets the iterator with countdown[Symbol.asyncIterator](). Then on every turn it calls next(), awaits the returned promise and repeats until done is true. While the loop waits, the call stack empties and the event loop gets on with other work; the page doesn't freeze.

A quick note: we could write for await directly because the console allows top-level await. In a classic <script> you need to put the loop inside an async function. In module scripts it also works at the top level, thanks to the top-level await we saw earlier.

Async Generators: async function*

Writing iterators by hand is instructive but tiring. On the sync side we saw how generators shorten that work. The async side has the same shortcut: async function*. You can use both await and yield in its body. Write the same countdown this way:

const wait = (ms) => new Promise((r) => setTimeout(r, ms));

async function* countDown(start) {
  for (let i = start; i > 0; i--) {
    await wait(500);
    yield i;
  }
}

for await (const n of countDown(3)) {
  console.log(n);
}

An object of nearly twenty lines shrank to a few. When you call an async generator, its body doesn't run right away; just like sync generators, it returns a paused object. That object is both an async iterator and an async iterable, meaning its Symbol.asyncIterator method returns itself. Each next() call advances the body to the next yield and delivers that value inside a promise.

Now we can go back to the opening question. numbers().next() returns a promise, so the first line prints true. The loop, however, throws: TypeError: ... is not iterable. The async generator object has Symbol.asyncIterator but no Symbol.iterator, so for...of can't find the door it's looking for. Add await next to for and 1 and 2 come through just fine.

The Finer Points of for await...of

for await...of doesn't only work with async iterables. Give it a sync iterable, such as an array of promises, and it awaits each element in order. We're using the wait function from the previous example:

const requests = [
  wait(300).then(() => "users"),
  wait(100).then(() => "products"),
  wait(200).then(() => "orders")
];

for await (const result of requests) {
  console.log(result);
}

The output is always users, products, orders, in that order; it doesn't matter which one finishes first. All three promises started the moment we built the array, so the total time is about 300 ms. But the loop keeps the order: it won't move to the second element before the first one resolves.

Then there's the matter of leaving early. When you exit the loop with break, return or an error, the engine calls the iterator's return() method. In an async generator, that means the finally block in its body runs. That's where you put things like closing a connection or clearing a timer:

async function* sensor() {
  try {
    let i = 0;
    while (true) {
      await wait(200);
      yield ++i;
    }
  } finally {
    console.log("Sensor closed");
  }
}

for await (const reading of sensor()) {
  console.log("Reading:", reading);
  if (reading === 3) break;
}

We wrote an endless while (true), but don't worry: a generator only moves forward when you ask it to. After the third reading the break kicks in, finally runs and Sensor closed shows up in the console.

Collecting Everything with Array.fromAsync

Sometimes you want the whole stream in a single array. On the sync side there's Array.from for that. Array.fromAsync, which came with ES2026, does the same job for async iterables and returns a promise:

const list = await Array.fromAsync(countDown(3));
console.log(list); // [3, 2, 1]

const squares = await Array.fromAsync(countDown(3), (n) => n * n);
console.log(squares); // [9, 4, 1]

The second argument is a map function, just like in Array.from. Although Array.fromAsync joined the standard in ES2026, it reached browsers much earlier: it has been available since Chrome 121, Firefox 115 and Safari 16.4, and since July 2026 it has been Baseline "widely available". You can use it with confidence.

The difference to keep in mind: Array.fromAsync waits for elements in order, one at a time, while Promise.all waits for all of them at once. If you already have an array of promises that have started, both finish in about the same time. But if the elements are produced one by one by an async generator, Array.fromAsync won't ask for the next one until the previous one has arrived. That laziness is usually exactly what you want.

Reading Paginated Data as a Stream

Async generators really shine with paginated APIs. The server hands out data twenty items at a time; you want to say "give me the posts one by one and deal with the pages yourself". The example below uses a public test API that allows CORS. Some sites block outgoing requests with their own security rules, so try this in the console of an empty tab (about:blank):

async function* posts() {
  let page = 1;
  while (true) {
    const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=20`;
    console.log("Sending request, page:", page);
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const items = await response.json();
    if (items.length === 0) return;
    yield* items;
    page++;
  }
}

for await (const post of posts()) {
  if (post.title.includes("sed ab")) {
    console.log("Found:", post.id, post.title);
    break;
  }
}

Count the requests in the console. The post we're looking for is on the third page, so only three requests go out; the remaining pages are never fetched. The consuming side knows nothing about page numbers, URLs or the last-page check; it just walks through posts. yield*, as we saw earlier, hands out the elements of an iterable one by one; here it pours a page's array into the stream.

A First Look at ReadableStream

The browser has ready-made async iterables of its own. The most important one is ReadableStream from the Streams API. The body property of a response you get from fetch is a ReadableStream: you can read it piece by piece (in chunks) as it arrives, before the response has fully downloaded. Each chunk is a Uint8Array, meaning raw bytes. We'll cover binary data separately in a later post; for now, "an array of bytes" is enough.

const response = await fetch("https://jsonplaceholder.typicode.com/photos");
let total = 0;
let chunkCount = 0;

for await (const chunk of response.body) {
  total += chunk.length;
  chunkCount++;
}
console.log(`${chunkCount} chunks, ${total} bytes in total`);

You can build your own stream too. With the controller passed to the start function, you add values to the queue (enqueue) and close the stream when you're done (close):

const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("Hello");
    controller.enqueue("stream");
    controller.close();
  }
});

for await (const chunk of stream) {
  console.log(chunk);
}

If you're reading text, you can pipe the stream through a TextDecoderStream instead of dealing with bytes: response.body.pipeThrough(new TextDecoderStream()) gives you string chunks directly.

Let's be upfront about support. ReadableStream itself has been everywhere for years, but iterating it with for await reached Safari last: the feature became Baseline in September 2026 with Safari 27, while Chrome 124 and Firefox 110 supported it much earlier. If you need to care about older Safari versions, the classic approach still works; you get a reader and read until done arrives:

const reader = (await fetch("https://jsonplaceholder.typicode.com/posts")).body.getReader();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log("Chunk:", value.length, "bytes");
}

Common Pitfalls

The most common mistake is forgetting await. As you saw in the opening example, iterating an async generator with for...of gives you an "is not iterable" error. The reverse is silent: iterating a sync iterable with for await works, it just adds a needless wait to every step.

The second is mistaking sequential waiting for parallel work. for await doesn't move on until a step is finished. If you await ten independent requests one by one inside an async generator, you'll wait ten times as long. For independent work, the Promise.all we saw earlier is still the right tool. Async iteration earns its keep where order or laziness matters.

The third is looking for iterator helpers on the async side. The helpers we saw earlier, such as map, filter and take, only exist on sync iterators. Write countDown(3).map(...) and you'll get a TypeError; their async counterparts are still a proposal at TC39. For now, you do the transformation with your own async generator:

async function* filter(source, predicate) {
  for await (const x of source) {
    if (predicate(x)) yield x;
  }
}

console.log(await Array.fromAsync(filter(countDown(5), (n) => n % 2 === 1)));
// [5, 3, 1]

Finally, trying to read a stream twice. While you iterate a ReadableStream, it's locked; a second reader at the same time gets a TypeError. When you leave the loop with break, the stream is cancelled by default and can't be read again. If you want to keep reading later, you need to write for await (const c of stream.values({ preventCancel: true })). A response body is single-use as well: once you've iterated body, you can't call response.json().

A Small Experiment

★☆☆ Using the wait function, write an async function* letters(word) that yields the next letter every second, and print the word "JavaScript" to the console letter by letter with for await.

★★☆ Predict the output before running it: for await (const x of [1, wait(100).then(() => 2), 3]) console.log(x); When are the non-promise values 1 and 3 printed, and does the order change? Then try it in the console.

★★★ Write an async function* firstN(source, n) that takes an async iterable and a number n, and stops after yielding n elements. Add a finally block to the posts generator, then try firstN(posts(), 5). Confirm in the console that only one request goes out and that finally runs.

In this post we used a special key like Symbol.asyncIterator to tell the engine "here's how to walk through me"; in other words, we plugged our own behavior into one of the language's protocols. In the next post we'll take that a step further and learn how to intercept every read, write and delete performed on an object: Proxy and Reflect.