DEV Community

Alex MacArthur
Alex MacArthur

Posted on • Originally published at macarthur.me on

Picking the Right Tool for Maneuvering JavaScript's Event Loop

Much of the time, you can get along just fine without thinking a ton about JavaScript's event loop. But sooner or later (especially as you begin to spend more time with things like the rendering process and asynchronous tasks) it becomes handy to know not only how the thing works, but the different tools available to best maneuver it.

By "maneuver," I mean "schedule code to execute at a particular part of an event loop iteration, or on a different one altogether." And which ones you choose in different situations can have a big impact on your code's performance.

A Brief Reacquaintance

Quick refresher on the event loop: it's the mechanism that coordinates when tasks are executed in relation to everything else running on the browser's main thread. When a page loads, the loop is constantly rotating, checking if different parts of the browser have tasks to execute. If they do, those pieces get temporary control to run a task they have available. Those "pieces" include pretty much everything – user input, rendering, network requests, and a bunch more.

Here’s how I attempt to visualize it in my head:

As it's turning, different queues are getting filled with things to do and waiting for their chance to move to the call stack for execution. You hear most mention of the two noted in that image: the main task queue and the microtask queue.

The Task Queue

The task (also: "callback" or "macrotask") queue is the primary queue that holds the callbacks of many browser APIs. For example, anytime you use addEventListener(), the browser throws a callback onto the task queue as soon as the event is triggered, and once the event loop gets back around to the queue, that task will move onto the call stack for execution.

const clickedCallback = () => {
    // Loaded onto task queue after button is clicked.
    console.log("clicked!");
};

buttonNode.addEventListener('click', clickedCallback);
Enter fullscreen mode Exit fullscreen mode

This queue is checked for something to run once per turn of the event loop. If something is found, it’ll execute the oldest task available (FIFO — ”first in, first out”), and then move on to the next thing.

There's a lot more to it than that, but if you'd like to go a little deeper, there are plenty of resources out there on the task queue. A couple of the best talks I've seen on it are from Philip Roberts and Jake Archibald.

The Microtask Queue

When everything on call stack has been executed (which may involve queueing more tasks for later execution), control is not given back to the event loop... yet. Instead, the microtask queue is given a chance to seize control.

You've probably engaged with this queue without even knowing it – anything in a .then() on a resolved Promise fires from this queue. Example:

Promise.resolve().then(() => {
  console.log('Fired from the microtask queue!');
});

setTimeout(() => {
  console.log('Fired from the task queue!');
}, 1000);
Enter fullscreen mode Exit fullscreen mode

What's special about the microtask queue is that control won't be given back to the event loop until it's completely empty. And that can be troublesome because microtask callbacks can themselves load more callbacks onto the microtask queue. Here's that flow:

If you're not careful, that can lead to a gnarly, thread-blocking delay, barring the event loop from doing anything else, including UI updates and handling user input. To further illustrate: the following CodePen runs two types of continuous loops for five seconds straight. The first uses setTimeout() to invoke itself over and over. The second uses queueMicrotask(), preventing the event loop from doing anything else until the microtask queue is empty and those five seconds are up.

Trigger one of those loops and then click the "increment" button.

See the Pen Blog Post :: Navigating the Event Loop :: setTimeout() vs. queueMicrotask() by Alex MacArthur (@alexmacarthur) on CodePen.

If you did it, you saw that you could still increment the count while setTimeout() was running. That's expected – between runs, the event loop can still make full rotation, handling other responsibilities like updating the UI. But while queueMicrotask() was running, it didn't have that privilege. Everything was frozen.

The Tools

With that bit of context, the browser comes with a set of tools for executing code while while the event loop is spinning. It's certainly not all of them, but probably some of the most common you'll see (and should maybe use) while building things.

#1. setTimeout(() => {}, 0)

Reach for this whenever you'd like to queue a callback to run on the soonest possible future turn of the event loop. I say "soonest possible" because it's technically not guaranteed be the next cycle. Instead, it'll depend on how quickly the browser can turn around and place a callback onto the task queue for execution. Even if you pass 0 for a delay, the actual minimum delay will vary between zero and four milliseconds, depending on its usage.

A common reason to enqueue work like this is to avoid stalling the event loop for a single, big task. A lot runs on the same thread as the event loop is turning. And if that one task holds everything up for too long, the browser's snappiness will suffer. Scheduling tasks to run over multiple turns allows other important tasks to be addressed while a single, greater chunk of work is in-progress.

Let's say you're working with a large list of items, each of which need to be go through an expensive process. You could process that entire list synchronously:

function doExpensiveProcess(item) {
  console.log('PROCESSING:', item);
}

function processItems(items) {
  const item = items.shift();

  doExpensiveProcess(item);

  if (items.length) {
    processItems(items);
  }
}

processItems([1, 2, 3]);
Enter fullscreen mode Exit fullscreen mode

But that would mean nothing else in the browser could occur until the entire list finished. User events wouldn't yield any sort of response. Animated GIFs would freeze. It'd be frustrating.

So, we'll queue up each item to be processed as a separate task instead:

function doExpensiveProcess(item) {
  console.log('PROCESSING:', item);
}

function processItems(items) {
+   setTimeout(() => {
        const item = items.shift();

        doExpensiveProcess(item);

        if (items.length) {
            processItems(items);
        }
+   }); <-- `0` by default
}

processItems([1, 2, 3]);
Enter fullscreen mode Exit fullscreen mode

This time, while the list is being processed, the event loop will have a chance to check in on other tasks, making the user experience a little more seamless.

We can illustrate this with a similar example as before. Here are two buttons – one processes the list synchronously, and the other asynchronously.

See the Pen Blog Post :: Navigating the Event Loop :: Async vs. Sync by Alex MacArthur (@alexmacarthur) on CodePen.

As expected, the synchronous loop blocks everything until the full list is finished. The asynchronous, however, breaks the larger task up, so the user experience isn't as compromised. There's still a little jank, but not a complete deadlock.

In the Same Vein: MessageChannel()

If, for some reason, setTimeout() doesn't suit you, there's alternative I've seen used: MessageChannel():

const channel = new MessageChannel();
channel.port1.onmessage = () => {
    console.log("Fired on next event loop cycle!");   
};
channel.port2.postMessage(null);
Enter fullscreen mode Exit fullscreen mode

Admittedly, the advantages to this choice are a little muddy to me, but I have come across a couple of comments suggesting that since a MessageChannel() doesn't need to queue up timers to be managed by the browser, it has the potential to be the more efficient of the two. I can't speak to it any further than that.

#2. queueMicrotask(() => {}, 0)

There'll be a time when you want to fire a bit of code after the current task is complete, but before control is given back to the event loop for anything else to occur. That's the role of queueMicrotask(). It's a great tool for doing "just one more thing" after potentially more important work is wrapped up, all on the same iteration of the event loop.

I haven't come across a ton of practical use cases for it, but I have thought about a few contrived examples. It can be useful for...

...performing some final work after a series of complex logic.

Say you need to build a series of logs after someone clicks a button, causing a logs array to be filled. The callback contains some complex logic, including code paths that yield early returns. Throwing a callback on the microtask queue means your logging can be neatly removed from those various paths, helping it to feel more out of the way.

let logs = [];

function firstThing() {
    logs.push('log #1');
}

function secondThing() {
    logs.push('log #2');
}

function thirdThing() {
    logs.push('log #3');
}

function emitLogs() {
    console.log('Logs:', logs);
    logs = [];
}

document.getElementById('button').addEventListener('click', () => {
    // No need to chase various code paths.
    queueMicrotask(() => {
        emitLogs();
    });

    firstThing();

    if (someCondition()) {
        secondThing();
        return;
    }

    thirdThing();
});
Enter fullscreen mode Exit fullscreen mode

In cases like this, despite logic getting pretty thick, you'll still be able to cleanly execute some code after every possible path.

... reliably dispatching an event only after all event listeners are safely attached.

You might find yourself working with an intricate set of event listeners being wired up on a page. Using queueMicrotask() can make it easier to more deterministically signal that the UI is ready to go. Imagine this:

queueMicrotask(() => {
  // Emit event after UI is ready.
  document.body.dispatchEvent(new CustomEvent('ui:ready'));
});

document
  .getElementById('button')
  .addEventListener('click', () => console.log('button clicked!'));

document
  .getElementById('box')
  .addEventListener('mouseover', () => console.log('hover!'));

// ... more event listeners and other UI setup.
Enter fullscreen mode Exit fullscreen mode

Sure, the same event could have been dispatched after those event listeners were attached, but that requires the application to be architected in an arguably more prescriptive way, and it would also assume that no other UI setup is accidentally placed after the event is emitted in the future. Again, it's just a little more predictable and deterministic, with no risk of any other tasks from other parts of the browser sneaking in to delay it, since it'll still occur on the same turn.

... doing something after higher-priority actions take place.

If you're working on something with especially high performance concerns, queueMicrotask() can help ensure the most important work is prioritized in any given turn of the event loop. Let's say you're firing a series of functions doing something critical. Each function should be logged as it's triggered, but you don't want that logging action to slow down the primary work at all. Throwing that logging work onto the microtask queue can ensure nothing gets in the way of what matters most:

function firstThing() {
  console.log('first very important thing.');

  queueMicrotask(() => {
    console.log('send log');
  });
}

function secondThing() {
  console.log('second very important thing.');

  queueMicrotask(() => {
    console.log('send another log');
  });
}

firstThing();
secondThing();

// Output:
// first very important thing.
// second very important thing.
// send log
// send another log
Enter fullscreen mode Exit fullscreen mode

Notably, while these callbacks don't halt primary tasks, they also don't risk getting blocked by anything else the event loop might permit to occur after it's done – it's all happening on the same iteration.

#3. requestAnimationFrame(()=> {});

This one's useful whenever you need to execute code in coordination with the browser's repaint cycle. The event loop spins at however quickly it's able to execute tasks, but most devices paint screen updates at a rate of 60 times per second.

The most obvious perk to requestAnimationFrame() is its ability to orchestrate smooth animations. Reaching for setTimeout() or setInterval() to rotate infinitely rotating something, for example, "works," but since it's removed from how the browser updates what a user sees, it can make for missed frames and some roughness to an animation. Here's an example with two spinning things – one with setTimeout(), and the other with requestAnimationFrame():

See the Pen Blog Post :: Navigating the Event Loop :: setTimeout() vs. requestAnimationFrame() by Alex MacArthur (@alexmacarthur) on CodePen.

If you watch closely, you should see a smidge of weirdness with the animation on the left. Its rotations aren't happening with the repaint cycle in mind. It's just marching forward as programmed, producing a bit of jank. The second, however, only modifies the DOM when the browser's about to perform a paint, meaning the frames are updated in harmony with what's shown on the screen, yielding a smoother animation.

But it's helpful for more too – like surgically handling CSS transitions on HTML elements. Say you want to slide open a box with an unknown amount of content inside it. You might've used a trick in the past by setting a max-height on a box to value you know to be higher than the box's actual height:

<style>
    .box {
        /* Other box styles... */

        transition: max-height 0.5s;
        max-height: 0;
    }

    .is-open {
        // Hope 500px is taller than the box!
        max-height: 500px;
    }
</style>

<button id="button">Open Box</button>

<div class="box">
  An unknown amount of content.
</div>

<script>
    document.getElementById('button').addEventListener('click', () => {
        box.classList.add('is-open');
    });
</script>
Enter fullscreen mode Exit fullscreen mode

It'll slide open, but it's also a bit of a guessing game. If you choose a value too low, the box won't completely open. But if the value's too large, the animation will be wasteful, enduring longer than necessary. Using requestAnimationFrame(), however, will allow you to accomplish it with more precision, in one fell swoop:

  • Expand the box completely.
  • Measure its rendered height.
  • Schedule a height change to the calculated value after the next repaint in the browser, triggering an animation.
<style>
    .box {
        /* Other box styles... */

        display: none;
    }
</style>

<!-- Box HTML goes here. -->

<script>
    document.getElementById('button').addEventListener('click', () => {
        const box = document.getElementById('box');

        // Allow the box to render.
        box.style.display = '';

        // Measure the actual height.
        const height = `${box.clientHeight}px`;

        // Set a starting height of 0 pixels.
        box.style.height = '0px';

        // Before the next repaint.
        requestAnimationFrame(() => {

            // After the next repaint. 
            requestAnimationFrame(() => {
                box.style.height = height;
            });
        });
    });
</script>
Enter fullscreen mode Exit fullscreen mode

That nested requestAnimationFrame() is critical. In order for the animation to be invoked, the updated height value must be applied after the browser's had a chance to repaint after setting the initial 0px height. Without it, those two DOM changes will be batched together, and the open box will just "pop" onto the screen.

Here's a simple demonstration of how requestAnimationFrame() animates it as desired.

See the Pen Blog Post :: Navigating the Event Loop :: Opening Box w/ Dynamic Height by Alex MacArthur (@alexmacarthur) on CodePen.

This particular tool is one that keeps surprising me with its applications. It's personally starling how useful it is to schedule work around the browser's repaint cycle.

#4. requestIdleCallback(() => {})

This one's best for executing lower-priority code at any future turn of the event loop, whenever the browser consider itself to be "idle," or as MDNstates: "when it determines that there is free time to do so."

It stands apart from the other tools here because there's really no telling on which turn of the event loop a callback will be fired. By using it, priority is ceded to other, more important tasks. In its simplest form, throw some work into requestIdleCallback(), and whenever the browser has a minute, it'll be queued for execution:

requestIdleCallback(() => {
    console.log("low priority stuff.")
});
Enter fullscreen mode Exit fullscreen mode

But it also gives you additional tooling for fine-tuning. Your callback will receive an IdleDeadline object indicating approximately how much time you have remaining in the current idle period. And that can be useful for scheduling a larger task that might need to be broken up over multiple idle periods.

For example, let's say your application has built collection of messages it wants to eventually send whenever the thread is idle. The provided IdleDeadline can help you process as many as possible during the browser's downtime, and then put off any remaining messages until the next idle period:

const messages = ['first', 'second', 'third'];

function processMessage(message) {
    console.log('processing:', message);
}

function processMessages(deadline) {
    // We've got messages to process & time available.
    while (deadline.timeRemaining() > 0 && messages.length) {
        const message = messages.shift();

        processMessage(message);
    }

    // Ran out of time. Schedule remaining messages for next time.
    if (messages.length) {
        requestIdleCallback(processMessages);
    }
}

requestIdleCallback(processMessages);
Enter fullscreen mode Exit fullscreen mode

The only word of caution with this one is that, at the moment, it's not supported by Safari. That said, it's simple enough to wire up a fallback/polyfill, so you'll get the benefit of running low-priority work on uncongested event loop iterations for the bulk of your users.

The TL;DR

That's a lot. Here's a summary of when I'd reach for these tools, depending on when it's in your best interest to schedule work.

  • setTimeout(() => {}, 0) - You'd like to spread high-priority work over multiple event loop turns, in order to avoid preventing all other tasks on the main thread from being handled.
  • queueMicrotask(() => {}) - You have a piece of work is relatively less important than what's currently on the call stack, but you still want it to be completed before the event loop allows anything else to happen.
  • requestAnimationFrame(() => {}) - You want something to happen in coordination with the repaint cycle – usually right before or after a repaint has occurred.
  • requestIdleCallback(() => {}) - You have some low-priority work to complete, but you're fine with doing it whenever the event loop has some downtime.

What's Missing?

I know for a fact that there are several other tools out there for navigating the event loop in the ways described here, and probably even with other important considerations I haven't mentioned. If there's something you find yourself reaching for that's missing here, don't hesitate to share!

Top comments (0)