JavaScript Event Loop... to the rescue

JavaScript Event Loop... to the rescue

Let’s explore how to leverage the JavaScript Event Loop to make the UI buttery smooth and responsive without blocking any of the user actions and still being able to perform extensive computations, in a theoretically single threaded JavaScript universe.

Well, I know many of you will shout back on me and say we have Web Workers, but for the simplicity of the article and to stay in line with the underlying idea, I’d refer to it as single threaded and refrain from using Web Workers. I’m aware that the same problem statements described in the article can be solved using web workers, but the approach I’m going to discuss is easier to reason about and gels well with your existing code base without much changes.


JavaScript is single threaded and all operations are executed one after another, including the user generated actions.

We all know that JavaScript is single threaded and all operations are executed one after another, including the user generated actions (eg click, scroll etc) inside browsers. Now you might be wondering this would mean an utterly unresponsive UI if the underlying operations are expensive. And most of you at some point in your life, have already seen the browser alert for unresponsive script.

Here’s a demo of the problem statement.

// some boilerplate
const { crypto } = window;
const dec2hex = dec => ('0' + dec.toString(16)).substr(-2);
const generateId = () => Array.from(crypto.getRandomValues(
    new Uint8Array(20)), dec2hex).join('');
// product generation
const BRANDS = ['Nike', 'Adidas', 'Puma', 'Reebok', 'Vans'];
function getRandomProducts(total = 100000) {
    return new Array(total).fill(1)
        .map(a => ({
            id: generateId(),
            brand: BRANDS[Math.floor(Math.random() * 5)],
            isAvailable: Boolean(Math.floor(Math.random() * 2))
        }));
}
const brandFilterFn =
    brand => p => p.brand === brand;
const availablilityFilterFn =
    available => p => p.isAvailable === Boolean(available);
// products listing
function getList(products) {
    const availableByBrand = {};
    const unavailableByBrand = {};
    BRANDS.forEach(b => {
        const byBrand = products
            .filter(brandFilterFn(b));
    availableByBrand[b] = byBrand
            .filter(availablilityFilterFn(true));
    unavailableByBrand[b] = byBrand
            .filter(availablilityFilterFn(false));
    });
    return {
        available: availableByBrand,
        unavailable: unavailableByBrand
    };
}
function showProductList() {
    // performing some expensive computations
    // that will freeze the UI until the
    // operation has completed
    
    const products = getRandomProducts();
    const productList = getList(products);
    console.log(productList);
}
document.getElementById('showListBtn')
    .addEventListener('click', showProductList);
Demonstration of the problem statement.

If you run this code and click on the Show List button, the UI will get frozen and unresponsive until the showProductList method and all its internal method calls and operations has completed. The users cannot interact with your page in the meantime. But why does this happen?

To understand this, we need to know how the operations are executed in the JavaScript runtime engines implemented inside of the browsers. This article explains in relatively simple way the Concurrency model and Event Loop in JavaScript. You can read about it in more detail but the crux of it is that there exist a never ending, always waiting event loop that processes each of your instructions (and those generated by the user interactions with browser) one at a time, picking from a dynamic queue built out of all pending operations. The tricky fact however is that it’s not a granular operation that is processed at a time, but a stack of related operations (in our example, the whole showProductList and the entire related call chain).

// simplified event loop representation
while (queue.waitForMessage()) {
  queue.processNextMessage();
}
Simplified event loop representation

Now comes the interesting part, we can manipulate our execution by pushing parts of our operations on this queue (also known as deferred execution) and allowing other user interactions to be queued and processed in the meantime. Result is a non-blocking, highly interactive UI. But wait, does this mean any complicated Web API to deal with low level message processing in the browsers. Well, no! Welcome the familiar setTimeout.


Calling setTimeout will add a message to the queue after the time passed as a second argument. If there is no other message in the queue, the message is processed right away; however, if there are messages, the setTimeout message will have to wait for other messages to be processed. The notion of message here means a set of instructions that are required to be processed at a time. Having the idea of setTimeout to defer execution, let’s explore how we can improve our user interaction and the related expensive computation.

setTimeout along with Promise is a powerful tool for making a highly responsive UI even with underlying large computations

First let’s try to break down the operations into chunks that can be processed independently. We would use Promise in combination of setTimeout to break each of the functions in the call chain into smaller operations.

function deferredGenerateId() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(generateId());
        }, 0);
    });
}
function test() {
    console.log('start');
    deferredGenerateId().then(id => console.log(id));
    console.log('end');
}
test();
// start
// end
// 5d51c31718a0a23f182ad3e8fd5163e6d3ca56ff
Performing task in async manner

We see how we converted generateId into a deferred function that is using a combination of Promise and setTimeout to totally defer the execution and still giving you the ability to perform controlled operations. Now let’s make a generic defer function that will take a function and apply the same pattern to it.

function defer(fn) {
    return (...args) => {
        let resolve, reject;
        let promise = new Promise((...args) =>
            [resolve, reject] = args);
        setTimeout(() => resolve(fn(...args)), 0);
        return promise;
    }
}
const deferredGenerateId = defer(generateId);
deferredGenerateId().then(id => console.log(id));
Generic function to defer any synchronous task execution

Having this generic defer function implementation, let’s try to break our application logic into smaller units.

Processing large set of data in smaller chunks and pushing them over the event loop, would give and immediate boost in the responsiveness of the UI

getRandomProducts can be made into a deferred function as deferredGenerateId. However, we would want to further break the computation into smaller computations and compose the result. Let’s see how we do this.

function getProduct() {
    return deferredGenerateId().then( id => ({
        id,
        brand: BRANDS[Math.floor(Math.random() * 5)],
        isAvailable: Boolean(Math.floor(Math.random() * 2))
    }));
}
function getProducts(total) {
    return new Array(total)
        .fill().map(getProduct);
}
const deferredGetProducts = defer(getProducts);
function deferredGetRandomProducts(total = 100000) {
    let resolve, reject;
    let promise = new Promise((...args) =>
        [resolve, reject] = args);
    // process in chunks of 1000s
    const chunkSize = 1000;
    const iterations = total / chunkSize;
    const promises = new Array(iterations).fill()
        .map(() => deferredGetProducts(chunkSize));
    Promise
        .all(promises)
        .then(productPromisesArray =>
            productPromisesArray.reduce((res, arr) =>
                res.concat(arr), []))
        .then(allPromiseChunks => {
            Promise
                .all(allPromiseChunks)
                .then(products => resolve(products));
        });
    return promise;
}
// logs the products array after a while
deferredGetRandomProducts().then(p => console.log(p));
Breaking larger operations into smaller executable chunks

Now let's have a look at how we break down the getList function to process data in chunks.

const filterProductsByBrand = (products, brand) => ({
    brand,
    products: products.filter(brandFilterFn(brand))
});
const deferredFilterProductsByBrand = defer(filterProductsByBrand);
function deferredGetList(products) {
    let resolve, reject;
    let promise = new Promise((...args) =>
        [resolve, reject] = args);
    const available = {};
    const unavailable = {};
    const promises = [];
    BRANDS.forEach(brand => {
        promises.push(deferredFilterProductsByBrand(
            products, brand));
    });
    Promise.all(promises)
        .then((results) => {
            results.forEach(result => {
                available[result.brand] = result.products
                    .filter(availablilityFilterFn(true));
                unavailable[result.brand] = result.products
                    .filter(availablilityFilterFn(false));
            });
            resolve({available, unavailable});
        });
    return promise;
}
Using the chunked operations to get list of products

And finally the showProductList function.

function showProductList() {
    // performing some expensive computations
    // that will freeze the UI until the
    // operation has completed
    
    let resolve, reject;
    let promise = new Promise((...args) =>
        [resolve, reject] = args);
    deferredGetRandomProducts().then(products => {
        deferredGetList(products).then(productList => {
            resolve(productList);
        });
    });
    return promise;
}
Composing the function from multiple deferred functions

Here’s the final version, with a non-blocking UI.

We see that setTimeout along with Promise is a powerful tool for making a highly responsive UI even with underlying large computations. We can apply this pattern quickly without much disruption to the existing code to gain some responsiveness in the code. Of course, it’s not a holy grail and for other extensively complex scenarios, one should consider investing effort in implementing solutions using Web Workers.


The article was originally posted on medium.