The work of a software developer requires that we keep learning new things and follow the trends in the field. Whether to improve our position on the job market or simply to deepen our knowledge and productivity. Over the last two years, the things we have to follow came to include the first signs of artificial intelligence, in the form of various AI assistants, co-pilots and ChatGPT integrations that can replace common, routine processes and also generate solutions to more complex problems. Today, at the time of writing, the leading language models can generate a whole project from a single prompt in Claude Code Fable or ChatGPT Astra, a project that can be deployed, tested and run. From software and medicine to copywriting and translation, professionals in these fields now face waves of layoffs. This so-called technological unemployment is no longer discussed only in academia. It has become a topic and an everyday reality of white-collar professions, see here or here. It is not the first time, nor the last, that advances in technology make many jobs and fields irrelevant and, in turn, create new ones. In such cases the wheat is separated from the chaff, and it is legitimate to ask who will remain useful to corporations in this AI-powered market economy?

Junior positions seem to be the first and natural target. Superficial knowledge, an undeveloped ability to see technical problems in the wider context of technical, but also business requirements. AI is ceasing to suffer from this, whereas junior positions are known for it.

When I look at my own field of front-end and the back-end server runtimes node/deno/bun, a deeper knowledge of the Event Loop seems to me to be one of the few so-called “hard skills” that, in the world of Javascript, separate a junior coder from a senior software engineer. By understanding the Event Loop one can answer several important questions, for example: how is it possible that Javascript is a single-threaded programming language, and yet we do several things in it at once by means of asynchronous operations? Much could be written about the Event Loop. My ambitions are modest. I will look at the Event Loop in a way that lets me explain one of the most common questions at a job interview for a front-end developer position: in what order will the following operations be executed, and why:

setTimeout(() => {
  console.log('hey, from set timeout');
}, 0);

new Promise((resolve) => resolve('ahoy from promise')).then((v) =>
  console.log(v)
);

console.log('hello from console log');

A technical introduction to the problem

Javascript is single-threaded. If we leave aside the Web Workers API, this means that Javascript can process only a single operation at any one moment. Javascript is designed to process operations sequentially by the LIFO method (Last-In, First-Out), and until an operation is finished, it blocks all the operations that follow. Under normal circumstances, for example, after a click on a button one slow GET request to a remote API would block all user interaction; the whole user interface of the browser would freeze until the network request was settled. When Brendan Eich designed Javascript in the nineties, the processing of operations in Javascript was synchronous, with limited means of handling the events raised by the user’s interaction with the browser. It would be worth a historical probe to find out why Eich designed Javascript this way. Today, however, we know that web pages built on Javascript (that is, the whole web) do not behave like this. A user can interact with several parts of a web page at once. How can a single-threaded programming language achieve that?

Enter Javascript runtime

When we say Javascript, we have to realise that we are talking about a collection of libraries and components. Only by connecting them do we get a working whole – the runtime, the environment in which our application runs. At present we have a choice of two main types of runtime. It is either the runtime of a web browser, or the runtime of one of several server implementations, for example Node.js, Deno, or the newest one, Bun. Although runtimes in browsers and on servers share common elements, we also find important differences between them. These follow from the focus of the given type of runtime. Web browsers are oriented towards working with the DOM (window, document), user interaction (addEventListener), network operations by means of fetch or XMLHttpRequest, local storage in the browser (localStorage, sessionStorage) and so on. The Node.js or Bun runtime, by contrast, is oriented towards the environment of server applications, work with the file system and databases, or low-level operations on the operating system. Both types of runtime, however, implement the Event Loop. To keep things simple, from here on I will concern myself only with the runtime of web browsers.

The runtime of web browsers

The Javascript runtime of web browsers consists of the following parts:

  • Javascript Engine
  • Web APIs
  • Event Loop
  • Task queue and Microtask queue

The Event Loop is only one of the components, and it comes into play only when asynchronous code is processed. If our Javascript program uses only synchronous operations, the Event Loop will never be used. Even so, the Event Loop is one of the defining characteristics of Javascript, and thanks to it today’s modern web applications are full of asynchronous functionality. To understand the Event Loop, and the processing of asynchronous code in Javascript at all, it is good to recall how Javascript behaves with fully synchronous operations. For that, in turn, it is good to understand how the Javascript engine works.

Javascript engine

The imaginary heart of Javascript is the engine. The main functions of a JavaScript engine are parsing, interpreting and executing code. By parsing we mean the conversion of JavaScript source code into a so-called abstract syntax tree (AST), a structure the engine is able to process. By interpretation we mean that most modern engines first interpret the code and then compile it into machine code (just-in-time compilation – JIT). By execution we then mean that after the translation into machine code the engine starts carrying out the instructions and keeps the application running.

Examples of popular JavaScript engines:

  • V8: Used by Google Chrome and Node.js. It is known for its speed and efficiency.
  • SpiderMonkey: The first JavaScript engine, developed by Netscape, used today in Firefox.
  • JavaScriptCore (aka Nitro): Used by Safari and other Apple products.
  • Chakra: Used by Microsoft Edge (legacy), now replaced by the V8 engine in the new Edge (Chromium).

The JavaScript engine further provides two memory structures: the heap and the call stack. Their differences and workings would again deserve a separate article on memory management in Javascript. For a basic definition, read Wikipedia or prompt it out of ChatGPT.

What matters for understanding the Event Loop is that the Javascript Engine as such does not implement the processing of asynchronous code. All of Javascript’s asynchronicity has to be implemented elsewhere. Where? That is exactly the job of the Javascript Runtime, which adds to the Javascript engine the Web APIs (we are in the browser), the Event loop and the waiting queues, the Task queue and the Microtask queue. Fun fact: if Javascript were a purely synchronous programming language, it would get by with the heap and the call stack alone and would not need an Event Loop. But I am getting ahead of myself.

Heap

The heap is not all that important for understanding the Event Loop. The heap is a memory structure in which the Javascript Engine stores complex data types (Object, Array, Function, Map, Set, WeakMap, WeakSet). The size of the heap is given by the size of the RAM. Unlike the call stack, the heap is not organised sequentially; it is non-linear memory. The heap stores data whose type and size we do not know at compile time and which may change during runtime (assigning other values to the properties of objects, creating new properties, new objects and so on), so the heap is dynamic memory. Technically speaking, the heap is an implementation of the common design pattern of dynamic memory allocation, which means that it requires a Garbage Collector, which cleans up allocated memory during runtime according to whether some piece of the program still needs (holds references in the call stack to) the objects allocated on the heap. If not, the Garbage Collector under normal circumstances cleans the heap and frees the memory. The Garbage Collector is not 100% accurate, though, so in spite of its existence memory leaks can still occur.

Call Stack

The call stack, on the other hand, is a simpler, static memory and far more interesting for explaining the Event Loop, because it has to do with the order in which code is executed in Javascript. The Javascript engine works with the call stack only synchronously, step by step. Technically, the call stack is an implementation of the classic Stack design pattern, in which the element added to memory last is processed first (Last In, First Out, LIFO). And what exactly is stored in the call stack, and the order of what exactly does it determine? To keep things simple, we can picture a Javascript program as a collection of functions and variables, and the call stack makes sure that our functions are called and our variables declared in the right order and at the right time.

While complex data types (objects, arrays and so on) are stored on the heap, the call stack holds only references/pointers into the heap. What the call stack really does store are the values of primitive data types (string, integer, boolean and so on), and also the whole execution context of a function, by which we mean everything “that is needed to call the function”. Again, it would be appropriate to dive deeper into everything a function’s execution context means, but to keep things simple, the execution context includes:

  1. Variable Environment: A record of the variables and function declarations specific to the given function.
  2. Lexical Environment: References to the parent scope (lexical environment) and the variables defined with let and const.
  3. The this binding: The value of this in the current function.
  4. Specific details of the call: The arguments passed to the function and the mechanism of its return (that is, the place in the program to which the program should return once the function has finished).

If I let Claude generate a random example to show how the call stack works, let us take, say, the following code:

function multiply(x, y) {
  const result = x * y;
  return result;
}

function calculate() {
  const a = 10;
  const b = 20;
  const product = multiply(a, b);
  console.log(product);
}

calculate();

What exactly happens in such a simple example? The situation is as follows: first the function calculate(), or rather its whole execution context, is put on the call stack. Then the execution context of the function multiply() is added to the call stack. As soon as multiply() has been called and its result assigned to the variable product, the function multiply() and its execution context are removed from the call stack. Then the execution context of console.log() is put on the call stack. After the value “200” has been printed to the browser console, the execution context of console.log() is removed from the call stack. Finally the execution context of the function calculate() is removed as well.

Notice that the example contains no global variables, and that the call of the function calculate() itself is seemingly not inside any function whose execution context could be added to the call stack. So how do global variables and function calls in the global scope get onto the call stack?

Global Execution Context (GEC)

Everything in the global scope can be understood as being called inside one main function, whose execution context has a special name and a special behaviour: the global execution context. Let us modify the example above and add global variables to it.

var globalA = 'testGlobalA';
const globalB = 'testGlobalB';

function multiply(x, y) {
  const result = x * y;
  return result;
}

function calculate() {
  const a = 10;
  const b = 20;
  const product = multiply(a, b);
  console.log(product);
}

calculate();

If we accept that the global context of our scripts is treated as a kind of “main” function, our script will behave as follows: the global execution context is added to the call stack, the variable “globalA” is added to the variable environment, the variable “globalB” is added to the lexical environment, and a this binding is created, which in the browser environment will refer to the global object window. As for the window object, it is worth recalling that while var globalA is added as a property to the window object, variables defined with const or let are added to the global scope/global lexical environment, but not to the global object window.

Once all the functions defined “inside” the global execution context have been added to the call stack, processed and removed from it, only the global execution context remains on the call stack as the last function; in the end it, too, is removed, and the call stack is empty.

That, at least, is how it works in principle, as long as all the code the Javascript engine processes is synchronous. If our application (read: inside the global execution context) contains functions that are processed asynchronously, the situation is considerably different.

We said that the global execution context (GEC) is a kind of primary “main” function and the entry point of the application. It seems that it has to run the whole time, until all the code has been processed. But what about the asynchronous parts of the code? They can, after all, start further functions that return new values and create new execution contexts, so the global execution context has to be present on the call stack “somehow”. And how and when is asynchronous code called when the call stack is partly full, or when it is completely empty? And if the call stack is empty, how is it possible for anything to run in Javascript at all, for example when a user clicks a button and the callback of an event listener registered with addEventListener() is run? And what monitors the asynchronous callbacks of addEventListener, when the Javascript engine is entirely synchronous and does not even contain any addEventListener function?

Asynchrony in Javascript, or Web APIs, Task queues, Event loop

The engine of the Javascript programming language contains neither the console object nor its method console.log(). Which may come as a surprise. The fact is that functionality programmers take for granted, such as setTimeout, addEventListener, the operations for manipulating the DOM, or the asynchronous network requests fetch and XMLHttpRequest, is part not of the Javascript engine but of another component of the Javascript runtime: the Web API.

Web API

How exactly does the Javascript engine manage to cooperate with the Web API so that the Engine has access to the functions console.log() or setTimeout(), if they are not directly part of the engine? A Javascript Engine (V8, for example) has an internal registry of native functions, which are defined in the ECMAScript specification (e.g. Math.random(), Array.prototype.map(), Date.now()). From it the Engine recognises that setTimeout() is not a native function, and so it reaches into the global scope, or rather the window object, and tries to call the method window.setTimeout(). For even before the program is initialised, the Javascript runtime makes sure that the global execution context (and therefore the lexical environment of every function, see above) contains the objects that are part of the Web API, e.g. document, navigator, performance, Intl, crypto, console.

When I asked ChatGPT to explain to me how the method console.log() gets run, it wrote, wrongly, that console.log() is part of the Javascript engine itself. When I pointed out that this is not so, ChatGPT apologised and offered me a more accurate answer. I was curious why it had made the mistake. It blamed it on a mental model of how Javascript works that is supposedly widespread among developers (and therefore among bloggers too), in which console.log() is a synchronous operation and is thus processed directly on the call stack (which is true), and from this it follows that if something is processed directly on the call stack (and needs neither callbacks, which are processed asynchronously off Javascript’s main thread, nor therefore the Event loop), it must be part of the Javascript engine.

That is a mistake. The method console.log() does not exist in the Javascript engine; the engine knows nothing about it, except that it is not a native function. It hands its actual processing over to the Javascript runtime, where the browser (or nodejs) then carries the function out with low-level code in C++ by means of C++ bindings, which are the relations between a Javascript function and the C++ function that really does what we want from the Javascript function.

We now know roughly how it works when the Javascript engine needs to call a non-native function from the Web API. But what about asynchrony and the calls to setTimeout(), addEventListener() or fetch()?

Callbacks and Task Queues in general

As a single-threaded language, the Javascript engine needs to delegate the execution of asynchronous operations outside itself. Besides the Web API, the Javascript runtime contains further components that make asynchronous operations possible: the Task queues and the Event loop. All asynchronous operations in Javascript are handled by means of callbacks (and that includes await/async, which is sugar code that uses Promise-based asynchronicity). When the Engine comes across an asynchronous operation, it hands its processing over entirely to the Web API. If it is a timer such as setTimeout(), the Web API waits the given number of milliseconds; if it is a network request such as fetch, it is again up to the Web API to wait for the response to that network request. As soon as the Web API has finished processing the asynchronous operation itself, it takes the callback defined by the programmer and passes this callback function to a special component of the Javascript runtime, a queue called the Task queue or the Microtask queue.

The callbacks stay in the queues until the call stack is completely empty, or rather until only the global execution context (GEC) remains in it. As soon as only the GEC is on the call stack, the next component takes its turn: the Event Loop.

Task queue

The Task queue is the older of the two queues, and in a sense it is as old as Javascript itself. The very first versions of Javascript in Netscape Navigator already knew setTimeout() and could react to events raised by the user, and both needed a place where a callback waits until the engine finishes what it is doing at the moment. Formally, though, the Task queue (and the Event loop altogether) was first described by the HTML5 specification of the WHATWG organisation, which unified how all browsers are to behave in this respect.

The reason the Task queue came into being is the one we started with: a single-threaded engine must not wait for anything. The Web API therefore deals with the waiting outside the engine and then hands to the Task queue only the finished callback, which is processed when its turn comes. The Task queue (the HTML specification calls them tasks; in blogs we often also meet the name macrotasks) is the place for:

  • the callbacks of the timers setTimeout() and setInterval(),
  • the callbacks of events registered with addEventListener() (a click, a key press, a scroll…),
  • the network events of older APIs, for example onload on XMLHttpRequest or messages from a WebSocket,
  • messages between windows and workers sent with postMessage(),
  • and indeed the very running of a script from a <script> tag – so our global execution context, too, begins its life as a task.

Two things are worth mentioning. First, “Task queue” in the singular is a simplification. The specification allows a browser to have several queues (for example one for user interactions and another for timers) and to choose among them, so a browser may give a click priority over a timer. Within a single queue, however, the FIFO method applies. Second, the Event loop always takes just one task from the Task queue, and between two tasks the browser has an opportunity to repaint the page. That is why even setTimeout(callback, 0) does not mean “right away” but “at the earliest in the next turn of the Event loop”. With deeply nested timer calls, moreover, browsers raise the minimum delay to 4 ms.

Microtask queue

The Microtask queue is a good deal younger. The term microtask appeared around 2011, when engineers from Mozilla and Chromium were designing a replacement for the so-called Mutation Events – an old mechanism for watching changes in the DOM. Mutation Events fired synchronously on every single change to the DOM, which was slow and error-prone. The new MutationObserver API was meant to deliver changes asynchronously and all at once in a batch, yet still before the browser repaints the page or processes the next event. The Task queue was not enough for that, because between two tasks a repaint or the processing of another click can already happen. And so a new queue came into being, whose callbacks are processed as soon as the call stack empties, still before the next task.

The second and today the best-known use of the Microtask queue became Promises, standardised in ECMAScript 2015 (ES6). The ECMAScript specification calls them “jobs”, but the browser puts them precisely into the Microtask queue. Promises thereby received an important guarantee: the callback in .then() is never called synchronously, but not needlessly late either – always right after the currently running code has finished. Around 2019 browsers then added the function queueMicrotask(), with which the programmer, too, can schedule a microtask directly, without the detour through a Promise.

The Microtask queue is the place for:

  • Promise callbacks: .then(), .catch(), .finally(),
  • the continuation of an async function after await (async/await is built on top of Promises),
  • callbacks passed to queueMicrotask(),
  • MutationObserver callbacks.

And one more important rule: while the Event loop takes exactly one task from the Task queue at a given moment, it always empties the whole Microtask queue, including the microtasks that were added while it was being processed. If some microtask kept adding another microtask, the browser would never get to the click or to repainting the page, and the page would freeze – just as with an infinite loop in synchronous code.

Event Loop

The mysterious Event loop now turns out to be something relatively trivial. The Event loop is nothing more than one component of the Javascript runtime that keeps checking whether the call stack is full. If it is empty, the Event loop’s next job is to decide whether to take, by the FIFO method, the first callback from the Task queue or from the Microtask queue. That is all.

The script runs in the global execution context. setTimeout hands a timer to the Web API and, after 0 ms, its callback is placed in the Task queue. The callback from .then() of the fulfilled Promise is placed in the Microtask queue. console.log runs synchronously and prints “hello from console log” to the console. When the call stack empties, the Event Loop first processes the Microtask queue and prints “ahoy from promise”, and only then takes the task from the Task queue and prints “hey, from set timeout”. setTimeout(() => { console.log('hey, from set timeout'); }, 0); new Promise((resolve) => resolve('ahoy from promise')) .then((v) => console.log(v)); console.log('hello from console log'); Call stack Web APIs Event Loop empty stack? → first all microtasks, then a task Microtask queue Promise, queueMicrotask, MutationObserver Task queue setTimeout, events, network operations Elements Console Sources Network top ▾ Filter Default levels ▾ hello from console logscript.js:6 ahoy from promisescript.js:5 hey, from set timeoutscript.js:2 › global execution context setTimeout(…, 0) timer: 0 ms new Promise(…).then(…) console.log('hello…') console.log(v) console.log('hey…') (v) => console.log(v) () => { console.log(…) } 1. The script starts as a task. The global executioncontext is put on the call stack. 2. setTimeout() hands a timer to the Web API. After0 ms its callback is placed in the Task queue. 3. The Promise is fulfilled at once, so the callbackfrom .then() heads for the Microtask queue. 4. console.log() runs synchronously, right on the callstack, and prints the first line to the console. 5. The script has finished and the call stack is empty.The Event Loop takes over. 6. The Event Loop first empties the Microtask queue:the callback from .then() prints “ahoy from promise”. 7. The Microtask queue is empty, so the Event Looptakes the first task: the callback from setTimeout(). Result: “hello”, “ahoy”, “hey” – synchronous code first,then all the microtasks, and only then the tasks. Restart Pause Resume