JavaScript: Updating DOM during a long-running computation

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.