MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Async Iterators and Generators in JavaScript

Master async iterators, generators, and for-await-of for handling data streams.

JavaScriptAsyncGeneratorsES2018

By MinhVo

Introduction

JavaScript's asynchronous programming model has evolved dramatically — from callbacks to promises to async/await. But one pattern remained difficult: consuming data that arrives over time in a stream-like fashion. Pagination APIs, WebSocket messages, file reads, database cursors, and event streams all produce sequences of values that arrive asynchronously. Before ES2018, handling these patterns required either buffering all data into memory (wasteful) or writing complex recursive promise chains (error-prone). Async iterators and generators solve this elegantly.

JavaScript async programming

Async iterators extend the familiar iterator protocol to asynchronous data sources. An async iterator returns a promise for each value, and the for await...of loop handles the awaiting automatically. Async generators combine the generator function syntax with async iteration, letting you write stream-consuming code that reads like synchronous code. Together, these features provide a first-class abstraction for working with asynchronous sequences.

This guide covers the iterator and generator fundamentals, the async iterator protocol, practical patterns for real-world use cases, and the interop between async iterables and ReadableStream, RxJS observables, and Node.js streams.

Understanding Async Iterators: Core Concepts

The Iterator Protocol

The iterator protocol defines a standard way to produce a sequence of values. An object is an iterator if it has a next() method that returns { value, done }. When done is true, the sequence is exhausted.

function createRangeIterator(start: number, end: number) {
  let current = start;
  return {
    next() {
      if (current <= end) {
        return { value: current++, done: false };
      }
      return { value: undefined, done: true };
    },
  };
}

The Iterable Protocol

An object is iterable if it implements [Symbol.iterator]() which returns an iterator. This enables for...of loops, spread syntax, and destructuring.

const range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        return current <= last
          ? { value: current++, done: false }
          : { done: true };
      },
    };
  },
};
 
for (const num of range) {
  console.log(num); // 1, 2, 3, 4, 5
}

Generator Functions

Generator functions (function*) simplify iterator creation. The yield keyword produces values lazily, and the function's execution is suspended between yields.

function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}
 
const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2

The Async Iterator Protocol

An async iterator has a next() method that returns a Promise<{ value, done }>. This is the key difference — each value is produced asynchronously.

const asyncIterator = {
  current: 1,
  async next() {
    if (this.current <= 5) {
      await new Promise(resolve => setTimeout(resolve, 100));
      return { value: this.current++, done: false };
    }
    return { value: undefined, done: true };
  },
  [Symbol.asyncIterator]() {
    return this;
  },
};

Async Generator Functions

Async generators combine async function* syntax with yield and await:

async function* fetchPages(url: string) {
  let page = 1;
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    if (data.items.length === 0) break;
    yield data.items;
    page++;
  }
}

Asynchronous data flow

Architecture and Design Patterns

The Pagination Pattern

Consume paginated APIs using async generators. The generator handles pagination logic internally, yielding one page at a time. The consumer uses for await...of to process items.

The Stream Processing Pattern

Process data from WebSocket connections, Server-Sent Events, or file reads using async iterators. Each chunk of data is yielded as it arrives, enabling memory-efficient processing of large datasets.

The Backpressure Pattern

When the consumer is slower than the producer, implement backpressure by buffering values or pausing production. Async generators naturally handle this — the generator is paused until the consumer calls next().

The Transform Pipeline Pattern

Chain async iterators to create data processing pipelines. Each iterator transforms the data from the previous one, similar to Unix pipes.

Step-by-Step Implementation

Basic Async Generator

async function* countWithDelay(max: number, delayMs: number) {
  for (let i = 1; i <= max; i++) {
    await new Promise(resolve => setTimeout(resolve, delayMs));
    yield i;
  }
}
 
// Consuming with for-await-of
async function main() {
  for await (const num of countWithDelay(5, 1000)) {
    console.log(num); // Prints 1-5, one per second
  }
}

Paginated API Consumer

interface Page<T> {
  items: T[];
  nextPage: number | null;
}
 
async function* paginate<T>(fetchPage: (page: number) => Promise<Page<T>>) {
  let page = 1;
  while (true) {
    const result = await fetchPage(page);
    for (const item of result.items) {
      yield item;
    }
    if (result.nextPage === null) break;
    page = result.nextPage;
  }
}
 
// Usage
async function main() {
  const users = paginate(async (page) => {
    const res = await fetch(`/api/users?page=${page}`);
    return res.json();
  });
 
  for await (const user of users) {
    console.log(user.name);
  }
}

WebSocket Stream

async function* websocketMessages(url: string) {
  const ws = new WebSocket(url);
  const messages: any[] = [];
  let resolve: ((value: IteratorResult<any>) => void) | null = null;
 
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    if (resolve) {
      resolve({ value: data, done: false });
      resolve = null;
    } else {
      messages.push(data);
    }
  };
 
  ws.onclose = () => {
    if (resolve) {
      resolve({ value: undefined, done: true });
    }
  };
 
  while (ws.readyState === WebSocket.OPEN) {
    if (messages.length > 0) {
      yield messages.shift()!;
    } else {
      yield await new Promise<any>((res) => {
        resolve = (result) => res(result.value);
      });
    }
  }
}

Transform Pipeline

async function* map<T, U>(iterable: AsyncIterable<T>, fn: (item: T) => U) {
  for await (const item of iterable) {
    yield fn(item);
  }
}
 
async function* filter<T>(iterable: AsyncIterable<T>, predicate: (item: T) => boolean) {
  for await (const item of iterable) {
    if (predicate(item)) yield item;
  }
}
 
async function* take<T>(iterable: AsyncIterable<T>, count: number) {
  let taken = 0;
  for await (const item of iterable) {
    if (taken >= count) break;
    yield item;
    taken++;
  }
}
 
// Usage: chain transformations
async function main() {
  const pipeline = take(
    filter(
      paginate(fetchUsers),
      user => user.active
    ),
    10
  );
 
  for await (const user of pipeline) {
    console.log(user.name);
  }
}

Data streaming architecture

Real-World Use Cases

Database Cursor Iteration

Database cursors are a natural fit for async iterators. Instead of loading an entire result set into memory, iterate over rows one at a time:

async function* cursorQuery(db: Database, query: string) {
  const cursor = db.cursor(query);
  try {
    while (await cursor.hasNext()) {
      yield await cursor.next();
    }
  } finally {
    await cursor.close();
  }
}

File Processing

Process large files line by line without loading the entire file into memory:

import { createReadStream } from 'fs';
import { createInterface } from 'readline';
 
async function* readLines(filePath: string) {
  const rl = createInterface({
    input: createReadStream(filePath),
    crlfDelay: Infinity,
  });
 
  for await (const line of rl) {
    yield line;
  }
}

API Rate-Limited Scraping

Scrape APIs with rate limiting by yielding results with delays:

async function* rateLimitedFetch(urls: string[], requestsPerSecond: number) {
  const delay = 1000 / requestsPerSecond;
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    yield { url, data };
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}

Real-Time Event Processing

Process events from Server-Sent Events or message queues:

async function* sseEvents(url: string) {
  const response = await fetch(url);
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
 
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop()!;
 
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        yield JSON.parse(line.slice(6));
      }
    }
  }
}

Best Practices for Production

  1. Use for await...of for consumption — It handles promise resolution, error propagation, and cleanup automatically. Don't call next() manually unless you need fine-grained control.

  2. Always use try/finally for cleanup — When an async generator is abandoned (break, return, or error), the generator's finally block runs. Use it to close connections, file handles, and other resources.

  3. Implement backpressure — If the producer is faster than the consumer, buffer values or implement flow control. Unbounded buffering leads to memory leaks.

  4. Handle errors in the stream — Wrap the for await...of loop in try/catch to handle errors from the async source. Unhandled errors in async iterators crash the process.

  5. Use yield* for delegation — Delegate to another async iterable with yield* to compose generators without nesting.

  6. Avoid mixing callbacks and async iterators — Choose one pattern for each data source. Mixing patterns creates confusion and makes error handling inconsistent.

  7. Test with mock async iterables — Create test helpers that yield known values with configurable delays. This makes async iterator logic testable without real async sources.

  8. Consider memory implications — Async generators keep their execution context alive until they're consumed or abandoned. Don't hold references to large objects across yields.

Common Pitfalls and Solutions

PitfallImpactSolution
Not consuming the entire iterableResources not released (memory leaks)Use for await...of or explicitly call .return()
Swallowing errors in the generatorSilent failures, corrupted stateLet errors propagate; catch at the consumer level
Unbounded bufferingMemory exhaustionImplement backpressure or use bounded buffers
Mixing sync and async iterablesConfusion about when values are availableUse consistent async patterns throughout
Not handling done: trueInfinite loops or missed terminationAlways check the done flag when calling next() manually
Forgetting finally blocksLeaked resources (connections, file handles)Use try/finally in generators for cleanup

Performance Optimization

Async iterators have overhead from promise creation and resolution on each iteration. For high-throughput scenarios (millions of items), batch processing is more efficient:

async function* batch<T>(iterable: AsyncIterable<T>, size: number) {
  let batch: T[] = [];
  for await (const item of iterable) {
    batch.push(item);
    if (batch.length >= size) {
      yield batch;
      batch = [];
    }
  }
  if (batch.length > 0) yield batch;
}

Use Promise.all within batches for parallel processing while maintaining sequential batch ordering.

Comparison with Alternatives

PatternMemoryComplexityError HandlingBackpressure
Async IteratorsLow (lazy)Lowtry/catchNatural
CallbacksLowMediumCallback patternManual
Observables (RxJS)ConfigurableHighOperatorsBuilt-in
Streams (Node.js)LowMediumEventsBuilt-in
Promise arraysHigh (eager)LowPromise.allNone

Advanced Patterns

Async Iterator Composition

async function* merge<T>(...iterables: AsyncIterable<T>[]) {
  const promises = iterables.map(async function* (iterable) {
    for await (const item of iterable) yield item;
  });
  // Interleave values from all iterables
  const iterators = promises.map(i => i[Symbol.asyncIterator]());
  const results = await Promise.all(iterators.map(i => i.next()));
  // ... complex merge logic
}

Race Conditions in Async Iterators

When multiple consumers iterate the same async source, you need synchronization:

function shared<T>(iterable: AsyncIterable<T>) {
  const subscribers: Set<(value: IteratorResult<T>) => void> = new Set();
  let done = false;
 
  (async () => {
    for await (const value of iterable) {
      for (const subscriber of subscribers) {
        subscriber({ value, done: false });
      }
    }
    done = true;
    for (const subscriber of subscribers) {
      subscriber({ value: undefined, done: true });
    }
  })();
 
  return {
    [Symbol.asyncIterator]() {
      return {
        next() {
          if (done) return Promise.resolve({ value: undefined, done: true });
          return new Promise(resolve => subscribers.add(resolve));
        },
      };
    },
  };
}

TC39 Async Iterator Helpers Proposal

The async iterator helpers proposal (currently at Stage 3 in the TC39 process) brings built-in methods directly to async iterables, eliminating the need for custom utility functions. Once shipped, you will be able to chain map, filter, take, drop, flatMap, reduce, and toArray directly on any async iterable:

// Current approach — custom helper functions needed
const pipeline = take(
  filter(
    paginate(fetchUsers),
    user => user.active
  ),
  10
);
 
// With iterator helpers — native method chaining
const results = paginate(fetchUsers)
  .filter(user => user.active)
  .map(user => ({ name: user.name, email: user.email }))
  .take(10);
 
for await (const result of results) {
  console.log(result);
}

The proposal also introduces AsyncIterator.from() which converts any async iterable (including ReadableStream, Node.js streams, and database cursors) into an async iterator with all helper methods available. This creates a universal interop point between different stream implementations in the JavaScript ecosystem. The synchronous iterator helpers proposal is already shipping in modern browsers and Node.js v22+, so the async version will follow the same pattern once it reaches Stage 4 and browser implementations begin.

Interoperability with Node.js Streams and Web Streams

Node.js streams have implemented the async iterable protocol since Node.js v10, making it straightforward to consume them with for await...of. The web platform's ReadableStream also supports async iteration in modern runtimes:

import { createReadStream } from 'fs';
import { pipeline } from 'stream/promises';
 
// Node.js readable stream as async iterable
async function processLogFile(filePath: string) {
  const stream = createReadStream(filePath, { encoding: 'utf-8' });
 
  for await (const chunk of stream) {
    const lines = chunk.split('\n').filter(Boolean);
    for (const line of lines) {
      const entry = JSON.parse(line);
      await processEntry(entry);
    }
  }
}
 
// Web ReadableStream as async iterable
async function consumeWebResponse(response: Response) {
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
 
  // Manual approach using reader
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log(decoder.decode(value, { stream: true }));
  }
}

Converting between stream types is also possible. Libraries like readable-stream and the built-in stream.Readable.from() method accept async iterables and produce Node.js streams, enabling interop with libraries that expect stream objects rather than async iterables. For database drivers, packages like pg-cursor, mysql2, and the MongoDB driver expose cursor objects that implement the async iterable protocol, letting you process query results row by row without loading entire result sets into memory. This pattern is particularly valuable for ETL pipelines, data migrations, and report generation where result sets can contain millions of rows.

Future Outlook

Async iterators are gaining adoption across the JavaScript ecosystem. Node.js streams now implement the async iterable protocol. The Fetch API's ReadableStream can be converted to async iterables. Database drivers are adding async cursor support. The pattern is becoming the standard way to handle asynchronous sequences in JavaScript.

The next frontier is async iterator helpers — methods like map, filter, take, and drop that work directly on async iterables, similar to array methods. These are currently at Stage 3 in the TC39 process and will make async iterator composition even more ergonomic.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Async iterators and generators provide an elegant abstraction for working with asynchronous sequences. They combine the readability of for...of loops with the power of lazy evaluation and backpressure handling.

Key takeaways:

  1. Async iterators return Promise<{ value, done }> from next() — each value arrives asynchronously
  2. Async generators (async function*) combine yield and await for readable stream processing
  3. for await...of handles promise resolution and error propagation automatically
  4. Use try/finally in generators for resource cleanup
  5. Implement backpressure to prevent memory exhaustion from fast producers
  6. Chain async iterators with transform functions for composable data pipelines
  7. Use batching for high-throughput scenarios to reduce per-item promise overhead

Start by converting a paginated API call to an async generator. Then add filtering and transformation with helper functions. Experience how async iterators simplify complex asynchronous data flows compared to recursive promise chains.