JavaScript — Staying in the Event Loop

Exploring how a single-threaded language handles asynchronicity

Tommy M
12 min readSep 21, 2023

JavaScript is single-threaded, meaning it can only execute one thing at a time. It’s pretty fast, so we tend not to notice. But this does include waiting for timeouts and network requests. If our only thread is busy waiting for something, will the program simply hang? Why don’t my timers finish exactly when I tell them to, and — for that matter — why isn’t setTimeout even in the v8 source code? Let’s take a detailed look at asynchronicity in JavaScript.

‘Staying in the Event Loop’ by Midjourney

JavaScript’s Call Stack

JavaScript is usually a very performant language, but its one Call Stack is vulnerable to being held up by certain calls. This could be by a call to very CPU-intensive code. It could also be an action that needs to await user input or information from network requests.¹ In this latter case, we’re often waiting for conditions outside our control as the developer. If functionality like this is pushed straight into the call stack, the program will hang. This is all while waiting for a condition that might never fulfil.

setTimeout is a function that awaits a (very predictable) condition before running a callback. This visualisation shows a setTimeout being pushed into the call stack, but not its callback (at least directly — for now). We can see the middle logging statement doesn’t enter the call stack when we might expect. But, if it never makes it into the call stack, it will never run. It turns out it’s actually leaving JavaScript and disappearing into the browser temporarily. In the next section we explore this in detail.

As we’ll see later, “…and curry” will eventually make its way into the call stack. Just not in the same way as the other logging statements.

The JavaScript Runtime

  • JavaScript Engine: A component of the runtime environment. It handles interpreting and executing the code itself.² The Call Stack is a fundamental part of the JavaScript engine.
  • JavaScript Runtime Environment: The environment in which code runs. Its components include the JavaScript Engine, the Event Loop and Web APIs.

The idea that JavaScript can only do one thing at once is only partially true. It is correct that the JavaScript Engine can only do one thing at a time. The JavaScript Runtime, however, is capable of more. Asynchronous functionality gets pushed to the Web APIs, which are part of the runtime (within the browser) and not the JavaScript Engine.³

setTimeout is thus an example of something that is not a part of v8 or the JavaScript engine. It is a feature exposed by the browser/Web APIs.

The JavaScript Event Loop

Running a callback inside setTimeout thus causes the functionality to be pushed to these Web APIs. It is the browser, and not the JavaScript engine, that handles awaiting the timeout. When it’s ready, the callback then gets pushed into a Task Queue (also known as the ‘Macrotask Queue’, of which more later); also part of the browser.⁴ What ensues is a cyclic series of events known as the ‘Event Loop’.

  • setTimeout, fetch requests — and similar — are pushed to the Web APIs.
  • The condition (timeout, network response, etc) is awaited here.
  • When the condition has fulfilled and the callback is ready, it is pushed into the Task Queue.
  • The event loop will wait for the next time the stack is empty. It then loads a task from the Task Queue.
  • When both the stack and the Task Queue are empty, the engine goes to sleep until another task appears.

This cycle repeats for as long as the program is running. Let’s examine this with a simple example.

let todoList = [];

function addTask(task) {
todoList.push(task);
console.log('1 - Added a task!');
}

function completeTask(task) {
setTimeout(() => console.log('2 - Completed a task!'), 0);
removeTask(task);
}

function removeTask(task) {
todoList = todoList.filter((item) => item !== task);
console.log('3 - Removed a task!');
}

function main() {
// the actual task is irrelevant to the example here, I just needed to
// remind myself to feed the fish
addTask('Feed fish');
completeTask('Feed fish');
}

main();
Here, setTimeout resolves immediately because it has a timeout of 0. If it were given a timeout of, say, 5000ms, the callback would sit in the Web API (browser) layer for the full duration. It would then enter the Task Queue and wait for anything in the call stack at that time to finish running.

Intuitively, we might expect to see statement 2 log out before statement 3 because it is referenced first in completeTask. In practice, we see 3 first. It works this way because statement 2 gets called in the setTimeout, but its invocation is deferred to the Task Queue. The statement 3 gets immediately invoked and, once the stack is clear, statement 2 is loaded into the stack.

This is something that many new JavaScript developers encounter. They are told to use setTimeout with a timeout of 0 to defer a function until just a little later. We can see from the example above that the callback is being queued until the next time the stack is clear. setTimeout is thus not a guarantee that a function will run after a specific amount of time — it is effectively a ‘minimum’ time to execution.

The Macrotask and Microtask Queues

The previously mentioned Task Queue is also known as the macrotask queue (its v8 name). This is where callbacks to setTimeout or user events are processed. However, there is another queue which has existed in JavaScript for a while. Usage of this became much more widespread with the introduction of ES6 promises and generators in 2015.

The Microtask Queue

In addition to the macrotask queue, we have the “Promise Jobs” queue (as it is referred to in ECMA standards, henceforth alluded to by its v8 name, the “Microtask” queue).⁵ In every cycle of the event loop, everything in the microtask queue runs before the macrotask queue is visited.⁶ Each cycle of the event loop thus follows these steps:

  1. Wait for the stack to become empty.
  2. Push the first item from the microtask queue into the stack.
  3. Wait for this job to complete.
  4. Loop steps 2–3 until the microtask queue is empty.⁷
  5. Load a single task from the macrotask queue into the stack.
  6. This ‘tick’ of the event loop has completed; go back to step 1.

Native Promise objects make use of the microtask queue. This has the benefit of minimising the delay between the promise resolving and its callback running. Microtasks also gain priority over browser renders or handling of events. This ensures nothing can change the state of the system between microtasks.

  • Macrotask (Tasks) examples: setTimeout, requestAnimationFrame, browser rendering
  • Microtask (Jobs) examples: Promises, queueMicrotask, MutationObserver, process.nextTick (Node.js)

It’s easy to see the priority of different tasks/jobs with this example.

function main() {
// logs out last
setTimeout(() => console.log("1 - I am a MACROtask, that means big"), 0);

// logs out second
Promise.resolve()
.then(() => console.log("2 - I am a MICROtask, I get priority"));

// logs out first
(() => console.log("3 - I am not asynchronous"))();
}

main();
The priority is always: first inline code, then microtasks, and last macrotasks. This is irrespective of where the functions are declared.

Note that, unlike setTimeout, native Promises are a JavaScript object. They are built into the language and aren’t part of Web APIs. The microtask for the promise’s callback is created when it resolves.⁸

A more complicated example — nested asynchronicity

So, knowing what we do now about asynchronous priorities, in what order would the logging statements in this code block run? Examples this contrived obviously won’t ever be needed in production code (…we hope). Still, it’s worth having a play around with it to understand what’s going on.

Unsurprisingly, “5 — Not asynchronous” runs first because it’s the only statement that doesn’t disappear into a queue. The rest of the statements are visualised below; understanding it in detail is left as an exercise to the reader.

function main() {
setTimeout(() => console.log("1 - Timeout"));
Promise.resolve().then(() => setTimeout(() => console.log("2 - Timeout in Promise")));
Promise.resolve().then(() => console.log("3 - Only Promise"));
setTimeout(() => Promise.resolve().then(() => console.log("4 - Promise in Timeout")));
console.log("5 - Not asynchronous");
Promise.resolve().then(() => Promise.resolve().then(() => console.log("6 - Promise in Promise")));
setTimeout(() => console.log("7 - Timeout 2"));
}

// if you run this in your console, you get an extra statement logged
// between some of the above. can you figure out why it's in this position?
main();

The History of Asynchronicity in JavaScript

Callbacks

A typical strategy was once to pass callbacks as a parameter into functions which would then decide when to execute themselves.

doWork(...args, function callback() {
// ...
});

This is the JavaScript equivalent of ‘don’t call me, I’ll call you’. Callbacks were effective but not always intuitive in their execution order. Take this slightly more complex example (source):

workA(() => {
workB();
workC(() => {
workD();
});
workE();
});
workF();

It’s not easy to tell just by looking what order the methods are executed in. If done asynchronously, the order is: A, F, B, C, E and D. There is also the matter of ever-increasing indentation creating the dreaded pyramid of doom.

ES2015 — Promises, Generators

Promises had existed in libraries before being standardised in ECMAscript. Many JavaScript developers were thus already familiar with its .then() syntax. This was initially done without specifying the priority that should be given to promise jobs. It was later that the HTML5 specification introduced the rule that they go into the microtask queue.

​​returnsAPromise()
.then(callbackWithResolveValue)
.then(anotherCallback)
.catch(handleAnyPromiseRejectionsToThisPoint)
.then(() => {
// ...
});

How did promises work before ES6?

To outside observers, it seemed developers of promise libraries such as Q.js and when.js were in competition. They wanted to develop the quickest, most efficient route between promise resolution and callback invocation. To do this, they found ways to enter jobs into the various queues using the APIs available to them at the time. These were setTimeout and MutationObserver, the latter of which uses the microtask queue. setImmediate was also a popular choice but this was only available in Node.js (though browser polyfills do exist).

ES2017 — async/await

Async and await syntax made asynchronous JavaScript yet easier to read. It is a syntactic sugar which mimics the form of synchronous JavaScript. It also has the benefit of allowing users not to constantly need to declare where Promises are and how they’re being used. Awaiting methods also saves the need to chain .then() statements together.

const myFunction = async () => {
const data = await getMyData();
const books = await getBooksRead(data.id);
// ... do something else with data/books
}

Bear in mind that promises and async/await are not always functionally identical. I’ll revisit this a bit later.

ES2022 — top-level await

This is a relatively new feature. Scripts that make use of async/await functionality no longer have to shoehorn code into an IIFE.

(async () => {
// no longer necessary at the top level
await doesSomethingLater();
})();

// just do this in any script (in an environment where this is supported)
await doesSomethingElseLater();

Async JavaScript Tips and Tricks

Understanding how asynchronicity works in JavaScript allows us to implement some cool things. As mentioned, it isn’t ‘true’ asynchronicity. Callbacks are just stuffed back into a queue to be run along the same thread later. We can still, however, leverage its inner-workings to great effect.

Performing heavy work without blocking the main thread

In browsers, Web Workers are often the best way to achieve this.⁹ Still, there might still be situations where approaches like those below are useful.

Sometimes it’s not possible to avoid needing to crunch a lot of numbers. In a single-threaded environment, this necessarily means other events get blocked. The noticeable effects of this can be reduced by splitting up the work. In this first example, we implement an ‘asyncForEach’ (source) which runs each function asynchronously. After each iteration in the loop, it thus returns control to the main stack. This allows processing of other user events, making sure they don’t get congested.

Array.prototype.asyncForEach = () => {
this.forEach((fn) => {
setTimeout(fn, 0);
});
}

function veryHeavyFunction() {
const finishTime = new Date().getTime() + 2000;
while (new Date() < finishTime) {}
}

// blocks the main thread - UI becomes unresponsive
[...new Array(10)].forEach(veryHeavyFunction);

// does not block the main thread (much) - UI still mostly responsive
[...new Array(10)].asyncForEach(veryHeavyFunction);

In a similar fashion, work can be split up into chunks to periodically allow the main thread to do other things. This example (source) doesn’t loop through an array, but finds another way to split what would otherwise be a huge amount of work into bite-sized chunks. Between these, the main thread can process user events and the interface doesn’t freeze.

(() => {
// blocks the main thread until it's done
for (let i = 0; i < 1e9; i++) {}
})();

(() => {
let i = 0;
function count() {
do {} while (++i % 1e6 != 0);
if (i < 1e9) {
setTimeout(count, 0);
}
}
count();
})();

Conditionally pushing functionality to the event loop

Async/await and Promise syntaxes mostly have the same effect. Remember that everything after an await (in the same function) is kicked into the event loop. This example illustrates why this can be important.¹⁰

((condition) => {
doSomethingA();

if (condition) {
doSomethingB()
.then((result) => doSomethingWithResult(result));
}

/* doSomethingC never enters the event loop; it will always run before
doSomethingWithResult, irrespective of 'condition'. */
doSomethingC();
// ...etc...
})(true);


(async (condition) => {
doSomethingA();

if (condition) {
const result = await doSomethingB();
doSomethingWithResult(result);
}

/* if 'condition' is true, doSomethingC is also kicked into the event loop!
doSomethingC will run after doSomethingB, and will run asynchronously -
IF 'condition' is true. */

doSomethingC();
// ...etc...
})(true);

Async/await syntax often makes the order of execution of code clearer. It can, however, behave strangely if we don’t properly consider exactly what is going into the event loop and when.

In conclusion…

What did the despondent JavaScript developer say to his partner? “I’ll await your future resolution. Just promise you’ll callback!”

… 🤦‍♂️

Most JavaScript developers could go their entire careers without knowing much about the event loop. Still, it doesn’t pose too much in the way of complexity. You never know when you’ll need to write code that optimises for UI refreshes, micromanages network responses and such. Having a solid grasp of this topic could be very valuable for writing more efficient, non-blocking code. For people who work with JavaScript professionally, it’s a topic worth having a decent knowledge of. After all, you can’t optimise usage of the macro- or microtask queues if you don’t know they exist.

So I hope you’ve found this topic as interesting as I do. Remember — in an industry like this, keeping our skills up-to-date is paramount. Pick a great source of information and stick with it! So long as it helps you stay in the…eh…loop.

[1]: Calls can be either blocking or non-blocking. Calls of either effect usually behave the same in practice. ‘Blocking’, however, refers to non-JavaScript operations such as synchronous network requests or I/O operations. CPU-intensive code is considered ‘non-blocking’. This is because the thread is still running and hasn’t been blocked waiting for something external to the JavaScript Engine.

[2]: v8 is Google Chrome and Node.js’ JavaScript engine. Some other engines include SpiderMonkey (Firefox), ChakraCore (Edge) and JavascriptCore (Safari).

[3]: This article focuses predominantly on front end JavaScript. For Node.js, it’s very similar — just substitute Web APIs or ‘browser’ with C++ APIs.

[4]: In spite of its name, the Task Queue is a set and not a queue. In a queue, items would be processed FIFO. Here, the Task Queue takes the first runnable task instead of dequeueing the first one. ‘Runnable’ here is determined by the state of the task’s document.

[5]: The name for the various ‘queue items’ depends on the source. Macrotasks and microtasks are how they’re described in v8. Other sources might allude to them as Tasks and Jobs, or the Task Queue and the Job queue respectively (‘microtasks’ and ‘Tasks’ are different, even if unhelpfully named). Tasks and Jobs are distinct from each other in the kind of operation they describe.

[6]: Unlike the Macrotask Queue (cf. footnote 4), the Microtask Queue is a queue and not a set. It isn’t, however, a Task Queue (cf. footnote 5).

[7]: Microtasks can also queue themselves or other microtasks. If this happens too much then it can block the event loop from reaching the next ‘tick’. This is the same as having an infinite loop running in your code.

[8]: In these examples, I’ve used Promise (not part of Web APIs) because it’s likely more recognisable to most developers. If intentionally pushing something to the microtask queue, it’s better to use queueMicrotask. This is a part of Web APIs.

[9]: It is possible to run the same process on multiple threads in Node.js too, though this isn’t real multi-threading. It’s more similar to separate microservices running across different cores.

[10]: ‘Do something’ in these examples is written for brevity. If you want to try running these in your console to see, you can prepend each code block with this:

const doSomethingA = () => console.log('A');
const doSomethingB = () => Promise.resolve('B');
const doSomethingC = () => console.log('C');
const doSomethingWithResult = console.log;

--

--

Tommy M

Hi! My name is: "tommy". I love finding nuggets of wisdom in the otherwise banal events of day-to-day life.