A Powerful Trick to Batch Promises Using Async Generators In JavaScript
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 thePromise.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!
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!