Frontend Web Performance: The Essentials [1]

How To NOT Block The Browser — The Event Loop, Asynchronous Scheduling, Web Workers & Examples 🛑

Matthew Costello
8 min readJul 3, 2022
How To Not Block The Browser

Previously, on Frontend Web Performance:
[0]: Less Is More — The Browser Rendering Cycle, Hardware Acceleration, Compositor Layers, Tools & Examples 🧘‍♀

Browsers run most of their work on the main (or UI) thread — from rendering frames through the rendering cycle to handling events and running garbage collection.

This is a single thread where everything must be consecutively processed; anytime the main thread is busy executing your JavaScript, running a rendering cycle or collecting garbage, it cannot update the display or react to user interactions. Periods of unresponsiveness, frozen pages, or jank, are the results of blocking the main thread: a bad user experience, particularly if it is happening consistently.

Understanding important concepts such as the Event Loop, Web Workers, yielding to the browser, and how you can make use of asynchronous scheduling, will allow you to keep the main thread running as fast as possible — vital for a performant frontend in browsers.

Contents

  • The Event Loop
  • On the Main Thread
    - Yielding to The Browser
    - The Batching Implementation
    - See For Yourself
  • Off the Main Thread
    - With Workers
    - With The Backend
  • More Resources

The Event Loop 🔁

In simpler terms, you can picture the event loop as a constantly emptying queue, continuously having more tasks stacked on top. Anything that needs to run on the main thread, any user interaction, must wait its turn in this queue, for the previous tasks to be completed.

The event loop runs different levels of tasks — macro and micro (or tasks and jobs, relatively).

While at most one macrotask may run during an event loop iteration, following this task, every existing microtask will be executed; microtask completion is prioritised. Promise callbacks, for example, utilise microtasks. Were a microtask to queue more microtasks of its own, these would additionally be executed (to the extent of an infinite loop). Following the completion of the microtasks, the rendering cycle is free to run.

To achieve the best performance and user experience, all event loop tasks should be as lightweight as possible. Aside from implementations that simply execute faster, this can be accomplished through utilising asynchronous scheduling to yield to the browser, or executing less code (i.e. moving code off the main thread).

On The Main Thread 🧵

Yielding to The Browser

Firstly, asynchronous code in JS (via promises) still block the event loop with their execution; it is the ‘waiting’ part of the async code — i.e. waiting for a file to be fetched or a timeout to trigger — that is non-blocking.

The general idea of ‘yielding to the browser’ is that a large duration of code execution is split into smaller groups or batches. Within the gaps between the processing of each batch, provided by asynchronous scheduling, the browser is given a chance to run other tasks through the event loop.

Note that for scheduling these batches in a non-blocking manner, macrotasks must be relied upon. If multiple microtasks were queued, the queue would simply be exhausted in one (blocking) go.

Three main asynchronous scheduling functions can be leveraged to avoid blocking: requestAnimationFrame (RAF), setTimeout, and setImmediate. RAF is useful for scheduling work directly related to rendering per frame, but here we will focus on setTimeout and setImmediate, generally more appropriate for this use case.

SetTimeout:

  • Schedules a callback to run in some amount of time
  • Once the delay has completed, and this completion has a chance to be processed, the callback is pushed to the back of the macrotasks queue
  • If a timer is nested more than five levels deep (i.e. calls further setTimeouts recursively), the delay will be clamped to 4ms (for our yielding use case, this means any batch following the first five would each be delayed by a further 4ms, with an extra total delay of max(n-5)*4, 0) ms, where n is the number of batches)
  • Other conditions can extend delays, such as the device being in low battery mode, or if the CPU is under heavy load
  • Timers rely on interrupts from the underlying operating system, which costs some performance
  • Timer interrupts can force hardware out of low power states, increasing usage, to meet the browser’s timer callback frequency requirements (though this issue may just be a relic from older hardware)

SetImmediate:

  • Schedules a callback to run “immediately”, pushing the callback to the back of the macrotasks queue
  • Circumvents the use of timers

So which scheduling function should be used? It depends.

Generally, if you’re aiming for the highest throughput with the best performance, setImmediate is your go-to guy. If the throughput of your tasks is not vital for the application, setTimeout may be preferable, because: if you prioritise everything, nothing is prioritised 🧘

There’s just one problem here, which you may have already noticed: no browsers support setImmediate (aside from Internet Explorer, funnily enough!). Luckily, this clever polyfill will allow you to use it to full effect.

The Batching Implementation

I’ll be utilising promisified versions of setImmediate and setTimeout to keep things neat; no nested callbacks here:

function waitTime(delayMs) {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
function waitImmediate() {
return new Promise((resolve) => setImmediate(resolve));
}

Here’s how batching can be implemented in a general form, with your choice of ‘maxTimePerBatchMs’ and ‘waitFunction’ (waitImmediate()/waitTime(0)).

const maxTimePerBatchMs = 4;async function runBatchedProcessing() {
let batchStartTime = Date.now();
for (let i = 0; i < batchSize; i++) {
const batchItem = batchItems[i]
doSomethingWithItem(batchItem);
const currentTime = Date.now();
const deltaTime = currentTime - batchStartTime;
const deltaTimeExceededLimit = deltaTime > maxTimePerBatchMs;
if (deltaTimeExceededLimit) {
await waitFunction();
batchStartTime = Date.now();
}
}
}

The value for maxTimePerBatchMs should be small enough such that, when a batch is executed alongside any other potential work going on in the browser, it can all be processed within one frame. I recommend less than 5ms, but you should profile to find an optimal number. Keep in mind that, as we are checking the time after the processing of each item, the total processing time spent on a batch will usually be a tiny bit longer than the max value.

Also important to note, that either ‘Date.now()’ or ‘performance.now()’, may be used to get the current time, depending on whether microsecond or millisecond precision is required, relatively.

See For Yourself

Firstly, you can test the impact of SetTimeout clamping in this CodePen, by looking in the console:

Clamping CodePen

Next, we’ll compare no batching vs setTimeout vs setImmediate, looking into total execution time and blocking. When executing these examples, note that the first pass will be much slower as the browser has not yet compiled or optimised the code.

No Batching

Running this example, you should notice the main thread and animation are blocked for the duration of the processing:

No Batching Example
Blocked Main Thread

SetTimeout Batching

Running this example, you should notice the main thread and animation are not blocked for the duration of the processing. However, it is much slower than no batching — it may even ‘time out’:

SetTimeout Batching Example
Main Thread Unblocked, Relatively Large Gaps (Delays)

The (clamped) timeouts result in large gaps between the processing of each batch; if ‘maxTimePerBatchMs’ was reduced further, resulting in greater usage of SetTimeouts, you would have even slower times.

SetImmediate Batching

Running this example, you should notice the main thread and animation are not blocked for the duration of the processing. However, it is still a bit slower than the first example:

setImmediate Batching Example
Main Thread Unblocked, While Still Very Compact

Without using timeouts, setImmediate is capable of using a much lower ‘maxTimePerBatchMs’ value without gaining the same extreme delayed effect. While still yielding to the browser and leaving gaps for any other tasks to run in between, such as the rendering cycle, the total processing is much more compact and efficient.

In conclusion, there are use cases for each method, but clearly, setImmediate is the most efficient overall (remember it will require a polyfill!). While these batching methods may extend the total processing time, in most cases users will perceive smoother performance.

Off the Main Thread 🚀

With Web Workers

Web Workers allow for the utilisation of extra CPU threads, capable of running slow/blocking JS without any impact on the responsiveness of the application. The same execution time, just without the UI blocking.

They are somewhat limited in that you must rely on the postMessage function for all cross-thread communication, which only supports certain types through its structured cloning algorithm. Also, Workers do not have any access to the DOM.

If you can work around these pain points and don’t require DOM manipulation, Workers may be perfect for you. While somewhat niche, particular use cases include:

*Some of these use cases may even be better suited for the GPU, thanks to the power of parallelisation — through WebGL (or the upcoming WebGPU). But that’s another can of worms.

Do note that using postMessage still takes up time on the main thread, scaling with the complexity/size of your message as it is copied. While usually extremely fast, ensure you’re not creating a new source of jank here — simple JS tasks are unlikely to benefit from Workers.

For lightning-fast messaging times, you may skip the copying and transfer ownership of memory directly with transferrables, great for large arrays of numbers or images (i.e. ArrayBuffers/ImageBitmaps) — as long as you don’t have any further use for the transferrable on the original thread. Balanced batching of individual messages into larger messages can be useful for reducing postMessage call frequency if that turns out to be a bottleneck.

Also, Web Worker creation is not free and threads are limited, so don’t go overboard!

With The Backend

Another (less exciting) option for moving code off the main thread is to remove it from the client entirely and offload it to a server. This is a more common approach, with its pros and cons, and I would be remiss not to include it — but we’re focusing on the frontend 😉

And there you have it — this is how you can avoid blocking the browser. Thanks for reading, and stay tuned for Frontend Web Performance: The Essentials [2]!

--

--

Matthew Costello

Exploring design & performance. Software Engineer at PreciPoint. Australian in Germany.