DEV Community

Cover image for Async Generators Are Underused β€” Here's How to Change That

Async Generators Are Underused β€” Here's How to Change That

We all want to build memory-efficient apps that can handle heavy loads. To achieve this, we optimize them using separate workers, batch processing, and other techniques. Yet, despite these efforts, you rarely see asynchronous generators used - even though their core concept is both lazy and async. You pull the next value only when you're ready for it, making them inherently memory-efficient and naturally backpressure-aware.

Part of the reason is cultural: most tutorials reach for while(true) loops, callback-based streams, or one-shot await calls because that's what people are familiar with. Async generators require a slight mental shift - from pushing data at a consumer to pulling it on demand - and that model isn't well-represented in most learning resources. I want to change that by sharing a few of my favorite practical patterns.


Pagination / Database Cursor

Case: You need to send a personalized newsletter to 100m+ users

Knowing that you need to fetch a large volume of user data from the database and then iterate over it to create a personalized newsletter for every single user, your first thought should be how to efficiently paginate over the records. Of course, you could define limit and offset variables and use a for loop to process data in batches from the database, but I suggest using an async generator to create a custom iterator instead.

type CreateGetUsersIteratorOptions = {
  batchSize?: number;
  where?: string[] | ObjectLiteral[] | Brackets[] /// etc...
};

async *createGetUsersIterator(options: CreateGetUsersIteratorOptions): AsyncGenerator<User[]> {
  const { batchSize = 100, where = [] } = options;

  let offset = 0;

  while (true) {
      const query = userRepository
        .createQueryBuilder('user')
        .orderBy('user.id', 'ASC')
        .skip(offset)
        .take(batchSize);

      if (where.length) where.forEach((i) => query.andWhere(i));

      const batch = await query.getMany();

      if (batch.length === 0) break;

      yield batch;

      if (batch.length < batchSize) break;

      offset += batchSize;
    }
};

// Usage example for the case

const iterator = createGetUsersIterator();

for await (const users of iterator) {
  await sendNewsletter(users);
}
Enter fullscreen mode Exit fullscreen mode

By encapsulating pagination logic inside an async generator, you get a clean, reusable abstraction that keeps memory usage flat regardless of dataset size - you always hold at most one batch in memory at a time. The caller just iterates with for await...of, completely unaware of the underlying batching mechanics. This separation of concerns means you can tune batch size, swap the data source, or add filters in one place without touching any consumer code.


Handling Large Files

Case: You need to parse and process a multi-gigabyte CSV export (logs, transactions, etc.)

Loading a file of that size into memory all at once will crash your process or at least spike RAM to unacceptable levels. The classic workaround - piping a ReadStream through an event emitter - scatters your logic across callbacks. An async generator lets you treat the file as a plain for await...of sequence instead, pulling one chunk at a time.

type CreateReadFileIteratorOptions = {
  chunkSize?: number;
  encoding?: BufferEncoding;
};

async function* createReadFileIterator(
  filePath: string,
  options: CreateReadFileIteratorOptions = {},
): AsyncGenerator<string> {
  const { chunkSize = 64 * 1024, encoding = "utf8" } = options;

  const stream = fs.createReadStream(filePath, {
    highWaterMark: chunkSize,
    encoding,
  });

  let buffer = "";

  for await (const chunk of stream) {
    buffer += chunk;

    const lines = buffer.split("\n");

    buffer = lines.pop() ?? "";

    for (const line of lines) {
      if (line.trim()) yield line;
    }
  }

  if (buffer.trim()) yield buffer;
}

// Usage example for the case

const iterator = createReadFileIterator("./transactions.csv");

for await (const line of iterator) {
  await processTransaction(parseCsvLine(line));
}
Enter fullscreen mode Exit fullscreen mode

Because the generator yields one line at a time, your heap stays constant regardless of whether the file is 10 MB or 10 GB. The highWaterMark option on the underlying ReadStream gives you a single knob to tune I/O throughput vs. memory pressure, while the consumer remains a clean for await...of loop with no knowledge of streams, buffers, or backpressure mechanics.


Streaming API Responses (LLMs, SSE)

Case: You need to relay an LLM's streaming response to your client in real time

LLM APIs (OpenAI, Anthropic, etc.) and Server-Sent Events deliver data incrementally - waiting to accumulate the full response before forwarding it defeats the purpose entirely and adds unnecessary latency. An async generator lets you consume the raw stream token by token and expose it as a clean iterable that any part of your application can for await...of over.

type CreateChatStreamIteratorOptions = {
  model?: string;
  maxTokens?: number;
};

async function* createChatStreamIterator(
  prompt: string,
  options: CreateChatStreamIteratorOptions = {},
): AsyncGenerator<string> {
  const { model = "gpt-4o", maxTokens = 1024 } = options;

  const stream = await openai.chat.completions.create({
    model,
    stream: true,
    max_tokens: maxTokens,
    messages: [{ role: "user", content: prompt }],
  });

  for await (const chunk of stream) {
    const token = chunk.choices[0]?.delta?.content;

    if (token) yield token;
  }
}

// Usage example for the case β€” forwarding tokens to an HTTP response (Express)

app.get("/chat", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");

  const iterator = createChatStreamIterator(req.query.prompt as string);

  for await (const token of iterator) {
    res.write(`data: ${JSON.stringify({ token })}\n\n`);
  }

  res.end();
});
Enter fullscreen mode Exit fullscreen mode

Wrapping the vendor SDK's proprietary stream in your own async generator creates a stable internal contract: swap OpenAI for Anthropic or any SSE source and only the generator changes - every consumer stays untouched. The generator also provides natural flow control at the application level: if your downstream processing is slow, the for await...of loop simply won't request the next token until it's ready, avoiding unbounded accumulation in your own code.


Conclusion

Async generators sit at the intersection of two ideas that are independently powerful - laziness and async - and genuinely rare to find combined so cleanly in a language primitive. Yet, browsing through most production codebases, you'll find manual offset loops, ad-hoc event emitter plumbing, and one-shot await calls where a single async function* would have been simpler, safer, and more memory-efficient.

The three patterns above barely scratch the surface. Rate-limited API crawlers, real-time WebSocket feeds, recursive directory walks, multi-step ETL pipelines - all of them are natural fits. The abstraction is always the same: hide the messy mechanics of how data arrives inside the generator, and let the consumer focus entirely on what to do with each piece.

Async generators have been part of JavaScript since Node 10 and are available in every modern runtime. The tooling is there. The next time you reach for a while(true) pagination loop or a stream callback chain, consider whether an async function* might say the same thing in half the lines - and mean it more clearly.

Link to my original post feel free to like it also, ofc if you liked this one)

Top comments (0)