When running a lengthy computation, it's considered good practice to provide an indication of progress to the user. This can be surprisingly difficult in client-side JavaScript, even with modern ECMAScript features like Promises and async/await. The difficulty arises with continuously-running computations. Such computations block the event loop and typically prevent the browser from updating the DOM. Which means, any progress indicators updated in the DOM will not be visible until the computation has completed. And that kind of defeats the purpose of progress indicators.
One way to solve this problem is to move computationally-intensive code into a web worker, which will run in the background and not block the event loop. And that's a good way to do it most of the time. However, web workers do have some limitations. Transferring large amounts of data from a web worker to the main thread is not straightforward, and might require copying it, effectively doubling the memory usage of a large data set. For example, I found myself facing this issue when processing a large time-series data set that needed to be passed to a visualization library in the main thread.
So here's another way to do it. The approach is a helper function, which I'll call doUntil
, which handles periodically yielding execution to allow the DOM to update. So rather than using, say, a for loop to iterate over the data, the body of that loop is encapsulated in a function and passed to the helper function. In addition to the loop body, the helper function takes two additional boolean-valued functions: one that returns true when it is time to temporarily yield execution, and one that returns true when the computation has completed. Here's an example that uses doUntil
to calculate the primes between 2 and 1000000 (full code).
function findPrimesNonBlocking() {
// Initialize loop variable and list of primes
let n = 2;
let primes = [];
// Yield every 1000 iterations and stop after 1000000
const stopCondition = () => n == 1000000;
const yieldCondition = () => n % 10000 == 0;
// Build the loop body to be passed to doUntil()
const loop = () => {
// Determine if n is prime
if (isPrime(n, primes)) {
primes.push(n);
}
// Increment n
n += 1;
// Update DOM if we're about to yield
if (yieldCondition()) {
document.body.textContent =
`Found ${primes.length} primes between 2 and ${n}`;
}
};
// Execute
doUntil(loop, stopCondition, yieldCondition);
}
In the above code, the loop
function encapsulates a single iteration of the computation, namely testing a single number for primality. The yieldCondition
function returns true every 10000 iterations, providing feedback frequently, but not so frequently that repeated DOM updates slow the browser. The stopCondition
function stops execution after the loop variable n
reaches 1000000. In this case, the code indicates status by reporting the number of primes found so far, but the entire array primes
is available to the main thread at all times, without the need to copy it. It's also worth noting that doUntil
returns a Promise, so it can be wrapped in async/await if desired, or a then
clause can be added.
Here's the code for doUntil
. It creates an outerLoop
function that repeatedly calls the provided loop
function, checking for yield and stop conditions between each iteration. When it is time to yield, setTimeout
is used to queue up the next iteration in the event loop, providing a chance for the DOM to update. Because loop
and even outerLoop
are called multiple times throughout the computation, the computation state (in this case n
and primes
) has to be stored elsewhere, namely in the scope of the calling function (findPrimesNonBlocking
in the example). The loop
function is also defined in the scope of the calling function, giving it access to the state variables.
function doUntil(loop, stopCondition, yieldCondition) {
// Wrap function in promise so it can run asynchronously
return new Promise((resolve, reject) => {
// Build outerLoop function to pass to setTimeout
let outerLoop = function () {
while (true) {
// Execute a single inner loop iteration
loop();
if (stopCondition()) {
// Resolve promise, exit outer loop,
// and do not re-enter
resolve();
break;
} else if (yieldCondition()) {
// Exit outer loop and queue up next
// outer loop iteration for next event loop cycle
setTimeout(outerLoop, 0);
break;
}
// Continue to next inner loop iteration
// without yielding
}
};
// Start the first iteration of outer loop,
// unless the stop condition is met
if (!stopCondition()) {
setTimeout(outerLoop, 0);
}
});
}
So if you've got a lengthy computation in the main JavaScript thread and you want to update a progress indicator in the DOM, this is one way to do it.