A Powerful Trick to Batch Promises Using Async Generators In JavaScript

A Powerful Trick to Batch Promises Using Async Generators In JavaScript
Photo by Aaron Thomas / Unsplash

Batching is great when you have to iterate over a collection and perform an  expensive async task on each iteration. Async Generators make this very easy to implement.

In this example we will look at a simple chunk by chunk batching of arrays with a tweak-able concurrency limit (number of tasks to batch in one go).
We will use a type called Task to represent an async computation which returns a Promise.

Task is any function that returns a promise. Promises are not inherently lazy, they execute as soon as they are constructed. Wrapping them in a function gives us control over when we want this promise to execute.

Implementation:

  • Given an array of  Tasks, we want to iterate over it in "steps" of our concurrency limit (batch size)
    We will use a simple for loop for this
  • We want to be able to execute the batch of tasks concurrently and wait for them. We will probably need to be in the execution context of an async function and await the batch of tasks to complete, using the Promise.all method.
  • Optionally, we want to attach a callback  which we can call when our batch of tasks has processed successfully
  • Finally, as a batch of tasks completes, we want to notify the consumer about the completion so that they can continue their async iteration

This last point is where Async Generators can help us do the magic! They let us use the await syntax inside an async generator function  (declared using the async function* syntax ) . This unlocks the ability to asynchronously iterate over promises and await them one by one.

Here's the execution of our plan in action:

/**
 * A number, or a string containing a number.
 * @typedef {(<T = any>() => Promise<T>)} Task
 */

/**
 *
 * @param {Array<Task>} tasks
 */
export async function* batchTasks(tasks, limit, taskCallback = (r) => r) {
  // iterate over tasks
  for (let i = 0; i < tasks.length; i = i + limit) {
    // grab the batch of tasks for current iteration
    const batch = tasks.slice(i, i + limit);
    // wait for them to resolve concurrently
    const result = await Promise.all(
      // optionally attach callback to perform any side effects  
      batch.map((task) => task().then((r) => taskCallback(r)))
    );
    // yield the batched result and let consumer know
    yield result;
  }
}

Async Generators also let us  yield these awaited values to the consumer of the generator function. This allows them to use the succinct for await (...) loop syntax to iterate over the data asynchronously 🎉

(async function () {
  for await (const batch of batchTasks(tasks, 5)) {
    console.log('batch', batch);
    //Do something with the processed batch
    renderPost(batch, appDiv);
  }
  loadingDiv.innerText = 'Loaded';
})();

This stack blitz shows a simple implementation of our task batcher. Dig in and have fun!

Batch Promises with Async Generators - StackBlitz
Blank starter project for building ES6 apps.

You can take this a step further by using a pool based approach to batching by using the Promise.race method to detect and fill empty spots in your task pool :D

Feel free to reach out and let me know how it works for you ;)

Happy Engineering!