Event Loop
Let's remember how the web browser event loop works. Event Loop consists of three main components:
- JavaScript engine – a component that executes JavaScript code and provides access to the Web API
- Web APIs – browser interfaces such as fetch, timers, and other functions
- Event queue – a data structure that accumulates UI events and Web API-initiated events, such as fetch execution results
It goes like this:
- Code Execution: First of all, the synchronous code on the call stack is executed until the Stack is emptied
- Event Queue Checks: After the call stack is emptied, Event Loop checks the event queue. For example, fetch has ended or we have subscribed to some UI Event like click, and this event was discarded
- **Pushing Events onto the Stack: **If there are pending tasks in the Event Queue, Event Loop pushes them onto the call stack for execution
- Repetition: This process continues in a loop, processing events and executing asynchronous tasks To better understand how it works, let's look at the diagram below:
- First, we execute the
fetch
request - This task is moved from the stack to the Web API
- Next, the browser executes the request and returns the execution results to the queue, in some of its internal representation
- Next, the Event Loop places the
fetch
results on the stack, where it is processed by the JS engine
💡Please note that Event Loop is not part of the JavaScript engine
Microtasks and Macrotasks
Before we look at the code examples, let’s define clearly what we mean by microtasks and macrotasks:
- Microtasks are deferred tasks that have a higher priority than macrotasks. Examples of microtasks are:
- Operations with promises (e.g.,
Promise.then()
,Promise.catch()
,fetch
, etc.) - Mutation queue operations (e.g., the ones used in the
MutationObserver API
to observe DOM changes) - Operations associated with queueMicrotask() – a function for explicitly adding microtasks
- Operations with promises (e.g.,
- Macrotasks are deferred tasks with lower priority than microtasks, e.g.:
- Timers handling (setTimeout, setInterval)
- User input events handling (e.g., clicks, scrolling)
- AJAX requests execution
Asynchronicity in the Web Browser
Let's look at the two types of asynchronous behavior available in a web browser:
- WebAPI asynchrony. Let's look at how fetch will work. We execute a fetch request, or rather the JS engine executes it; then the request is passed to the Web API side, while the main browser thread continues to work. After the fetch request is finished, the Web API returns the execution results to the queue, and then these execution results will be picked up by EventLoop and processed by the JS engine.
-
Asynchrony of JS code (pseudo asynchrony). What do you think will happen if we take a function that loops from 0 to 1,000,000,000, and then wrap it in a promise? That’s right, this will make the browser freeze. Why? The thing is, by wrapping the code in a promise, we are only asking the browser to postpone the execution of this code until the browser’s queue is processed. Wrapping synchronous code in a
Promise
or running it throughqueueMicrotask
only puts our code almost at the end of the queue. Why almost? We'll examine this further below. > 💡Is there a way to execute JS code asynchronously and in a separate browser thread? Yes, it is possible with the use of the Web Worker API. You can read about them here:
An Example with Macrotasks
Macrotasks are tasks like timers (setTimeout, setInterval) and they are scheduled to run consecutively.
console.log('Start');
setTimeout(() => {
console.log('Macrotask completed');
}, 0);
console.log('End');
In this example, the output order will be as follows:
Start
End
-
Macrotask completed
Even ifsetTimeout
has a delay of 0 milliseconds, its callback will not be executed immediately but will be scheduled as a macrotask and executed at the end of the execution priority.
An Example with Microtasks
Microtasks include, among others, Promises and handlers associated with them.
console.log('Start');
Promise.resolve().then(() => {
console.log('Microtask completed');
});
console.log('End');
In this case, the execution order will look like this:
Start
End
Microtask completed
Since microtasks have higher priority than macrotasks, the Promise callback is executed immediately after the current execution loop completes, but before the next event loop, even if there are already macrotasks in the Event Loop.
Combined Example: Microtask + Macrotask
Let's now take a look at a combined example that includes both microtasks and macrotasks:
console.log('Start');
setTimeout(() => {
console.log('Macrotask completed');
}, 0);
Promise.resolve().then(() => {
console.log('Microtask completed');
});
Promise.resolve().then(() => {
console.log('Microtask 2 completed');
});
console.log('End');
The execution order will be as follows:
Start
End
Microtask completed
Microtask 2 completed
Macrotask completed
This example shows that microtasks (here, promises) are executed before macrotasks (here, timers), even if they were scheduled later in the code. This highlights their priority in the Event Loop.
💡 Note the order of priority: first, all microtasks will be executed, and only then the macrotask will be taken for execution.
An Example with Page Rendering
When I wrote that the one macrotask is executed after all the microtasks, I simplified things a bit. In reality, page rendering may occur after the microtasks are executed. If you use requestAnimationFrame
, these calls can be executed before the macrotask execution begins. The browser tries to insert requestAnimationFrame
into the nearest rendering. All calls to requestAnimationFrame
try to happen approximately every 16.67 ms, but strictly after all microtasks are executed. Let's look at the example below:
console.log('Synchronized start');
//A macrotask with setTimeout
setTimeout(() => {
console.log('Macrotask 1: setTimeout');
}, 0);
//A macrotask with setTimeout
setTimeout(() => {
console.log('Macrotask 2: setTimeout');
}, 0);
//A microtask with Promise
Promise.resolve()
.then(() => {
console.log('Microtask 1: first promise completed');
});
.then(() => {
console.log('Microtask 2: second promise completed');
});
//First requestAnimationFrame to update animation
requestAnimationFrame(() => {
console.log('First requestAnimationFrame: animation update');
});
//Second requestAnimationFrame to update animation
requestAnimationFrame(() => {
console.log('Second requestAnimationFrame: animation update');
});
console.log('Synchronized end');
The output order will be as follows:
Synchronized start
Synchronized end
Microtask 1: first promise completed
Microtask 2: second promise completed
First requestAnimationFrame: animation update
Second requestAnimationFrame: animation update
Macrotask 1: setTimeout
Macrotask 2: setTimeout
We were lucky, and the rAF calls were completed before the macrotasks. If we run this code again, the order of rAF and microtasks may get swapped.
Example with a Macrotask Calling a Microtask
console.log('Script start');
setTimeout(() => {
console.log('Macrotask: setTimeout');
//A microtask created inside a macrotask
Promise.resolve().then(() => {
console.log('Microtask: processing a promise inside setTimeout');
});
}, 0);
Promise.resolve().then(() => {
console.log('Microtask: processing the first promise');
});
console.log('End of script');
The output order will be as follows:
Script start
End of script
Microtask: processing the first promise
Macrotask: setTimeout
Microtask: processing a promise inside setTimeout
Here it is worth noting the situation when a microtask is created inside a macrotask. Until the macrotask executes its callback, the browser won’t know there is a microtask inside of it.
Event Loop Contexts
In this part, I want to focus your attention on isolated contexts with their own event loops:
- Web browser main context
- Iframe
- Web Worker Each of these elements has its own unique event loop that does not overlap with the others. With Web Workers, everything is quite straightforward: a task is sent to the worker and is processed independently of the main browser thread.
With Iframe, the situation is a little more complicated. Although Iframe has its own event loop, it does not have a separate thread of execution. This does not mean, however, that a costly task in the Iframe will cause the main browser tab to freeze; only the iframe will freeze, and the main tab will continue to work even though the browser will notify the user that something is stuck.
The Philosophy of Microtasks and Macrotasks
Until 2015, the standard did not include microtasks, and until 2014, it did not include Web Workers. And requestAnimationFrame
appeared only in 2011. Developers had to be content with the asynchrony based on macrotasks or Timer API. Considering all this, macrotasks were and still are a powerful tool.
The overall setup is this: no macrotasks, no rAFs, no Web Workers, and no party. Imagine that you need to calculate something complex on the client, or you want to do custom animations that cannot be done through CSS, and you need to do it in such a way that the browser window is not blocked yet the task is completed. The browser has no multi-threading, we only have a single-threaded EventLoop. How should we deal with such a situation? We break the task into several subtasks and execute them one by one, gradually freeing up the process for rendering, events, and queries.
Let's look at an example:
function processArrayInChunks(array, chunkProcessingTime) {
let index = 0;
const startTime = Date.now();
function processChunk() {
const chunkStartTime = Date.now();
while (index < array.length && (Date.now() - chunkStartTime < chunkProcessingTime)) {
// A processing example: increase each element of the array by 1
array[index] += 1;
index++;
}
if (index < array.length) {
console.log(`Processed ${index} elements so far...`);
setTimeout(processChunk, 1000); // Schedule the next chunk immediately after the current one
} else {
console.log(`Completed processing ${array.length} elements in ${Date.now() - startTime} ms`);
}
}
processChunk();
}
// Creating a large array
const largeArray = new Array(1000000).fill(0);
// We start processing the array, limiting the execution time of the subtask to 17 milliseconds
processArrayInChunks(largeArray, 17);
What do we have here?
-
processArrayInChunks
gets the array and the processing time of each subtask. It initiates the process of dividing the task into parts. -
processChunk
is called recursively viasetTimeout
. It processes one part of the array at a time, limiting the execution time to a specified duration (chunkProcessingTime
). - The loop exit condition is determined when the current index reaches the end of the array. Once completed, the total processing time is displayed.
💡 To slow down the execution of the task, we had to run setTimeout at intervals of a second.
Thanks to processing one macrotask at a time, we achieved background task execution that doesn’t block the page.
Absolutely the same logic applies to browser events. The developer subscribes to some event, and it is obvious it will change the DOM state; after the changes, the browser process will be freed (for example, rendering will occur), and then it will take the next macrotask.
💡 By the way, yes, browser UI events are also macrotasks.
To summarize the above: macrotasks are a powerful browser mechanism designed for the “manual” non-blocking execution of large tasks. And by “big,” I mean “take a large task and break it down into small subtasks.”
requestAnimationFrame
The standard is evolving, and now it includes requestAnimationFrame designed to optimize rendering and save developers from performing custom complex animations using the Timer API.
Its core idea is to ask the browser to schedule a visual change on the nearest frame. And not just one change, but all rAFs that can be executed in the next frame. Let's look at an example:
let lastTimestamp = Date.now();
function heavyTask() {
const start = Date.now();
const workloadPeriod = Math.random() * 10;
while (Date.now() - start < workloadPeriod) {
// Artificial loading
}
console.log("Heavy task is finished!");
}
let frame = 0;
const runFrame = () => {
frame++;
console.log("frame", frame, Date.now() - lastTimestamp);
lastTimestamp = Date.now();
if (frame < 10) {
requestAnimationFrame(runFrame);
}
};
requestAnimationFrame(runFrame);
for (let i = 0; i < 50; i++) {
setTimeout(() => {
requestAnimationFrame(() => {
console.log("rAF from a macrotask", Date.now() - lastTimestamp);
});
heavyTask();
}, 0);
}
Let's break down what this code does. We launch our rAF, which is not loaded in any way for the frame log. Ideally, it is expected to run every 17ms. Next, through the loop, we create 50 macrotasks that stop our event loop for 0 to 10ms. We also display the frame number in the console and track the difference with the previous frame. This example demonstrates how the browser schedules rAF execution.
Let's first clarify what this code does. We run our rAF, which is not loaded in any way for the frame log; ideally, it is expected to run every 17ms. Further in the loop, we create 50 macrotasks that stop our event loop from 0 to 10ms. We also display the frame number in the console and track the difference with the previous frame. We need this example to demonstrate how the browser schedules the execution of rAF.
In the browser console we will see something like this:
frame 1
- rAF was processed 2ms after the start of our code executionHeavy task is finished!
- 15 macro tasks completedframe 2
- 104ms have passed since the last rAFrAF from a macrotask
- caused by 15 rAF with an interval of 0msHeavy task is finished!
- 18 macro tasks completedframe 3
- 105ms have passed since the last rAFrAF
from a macrotask - caused by 18 rAF with an interval of 0msHeavy task is finished!
- 16 macro tasks completedframe 4
- 102ms have passed since the last rAFrAF from a macrotask
- caused by 16 rAF with an interval of 0msHeavy task is finished!
- 19 macro tasks completedframe 5
- 102ms have passed since the last rAFrAF from a macrotask
- caused by 19 rAF with an interval of 0msHeavy task is finished!
- 20 macro tasks completedframe 6
- 104ms have passed since the last rAFrAF from a macrotask
- caused by 20 rAF with an interval of 0msHeavy task is finished!
- 12 macro tasks completedframe 7
- 64ms have passed since the last rAFrAF
from a macrotask - caused by 12 rAF with an interval of 0msframe 8
- 0ms has passed since the last rAFframe 9
- 13ms have passed since the last rAFframe 10
- 17ms have passed since the last rAF
Let me explain the console output a little. Each setTimeout
calls a rAF
and a heavy task blocking the event loop. Once the browser realizes that enough rAFs
have been accumulated for rendering, it attempts to apply the changes that were requested via rAF
from setTimeout. Judging by the log, the browser was able to execute 15-20 microtasks before executing rAF
requests, and on average, the browser scheduled the accumulated rAF
calls every 100ms.
What conclusion do we draw from this? First, the browser strives to execute rAFs
in the next frame (meaning every 17ms), and secondly, we clearly see that the browser is trying to schedule as many tasks as possible before the next rAF
call and tries to put as many rAF
calls initiated by macrotasks as possible into one frame.
💡 Let me emphasize here that this example illustrates the basic aspects of browser interaction with
requestAnimationFrame
. In practical use, calls torequestAnimationFrame
involve operations that affect the DOM and consume CPU resources. This may cause the browser to try to combine multiple changes into a single render cycle, but may also delay certain DOM changes until the next frame to optimize performance.
Microtasks
Further along the timeline, the standard incorporates microtasks designed to optimize the browser rendering process and collect the maximum number of application state changes that will subsequently affect the DOM, and only then perform rendering.
💡 The deal here is that the browser rendering process is a very expensive and complex operation. Browser developers optimize it as much as possible; for this, additional event loop actors appear in the standard, such as rAF and the concept of microtasks.
To prove that rendering will be completed after all macrotasks have been executed, let's run the code below:
Promise.resolve("Data loaded").then((message) => {
console.log(message);
requestAnimationFrame((timestamp) => {
console.log('Animation for:',message, timestamp);
});
});
Promise.resolve("Settings loaded").then((message) => {
console.log(message);
requestAnimationFrame((timestamp) => {
console.log('Animation for:',message, timestamp);
});
});
Promise.resolve("User data loaded").then((message) => {
console.log(message);
requestAnimationFrame((timestamp) => {
console.log('Animation for:' ,message, timestamp);
});
});
The output order will be as follows:
Data loaded
Settings loaded
User data loaded
Animation for: Data loaded 9516312.2
Animation for: Settings loaded 9516312.2
Animation for: User data loaded 9516312.2
As you can see, all rAF
calls were executed after microtasks and also have the same timestamp. This tells us that in our case, the browser has scheduled the animation (the accompanying DOM changes) in the next page refresh cycle.
At the end of this block, I would like to once again highlight the differences between microtasks and macrotasks:
- The browser executes all microtasks in a row and then proceeds to rendering and to macrotasks
- Microtasks are executed without any delays, as, for example, can happen with the Timer API (minimum delay of 4ms)
- Macrotasks are executed according to the following principle: one macrotask occupies the process, after which the event loop must be unlocked for other tasks, and only after that the next macrotask will be taken – in case there is one, naturally
- Microtasks have a guaranteed order of execution. That is, if you decide to postpone some tasks using microtasks, they will be executed exactly in the order in which they were created.
You might be wondering, what's with the
fetch API
? Fetch is a promise, which means it’s a microtask, which means it must be executed according to the order in which the microtasks are called! When you usefetch
to make an HTTP request, it works asynchronously, returning a promise. This promise is not a microtask in itself, but it will be added to the microtask queue after the request completes (the result is received or else the request fails). ```ts
console.log('Script start');
// Asynchronous request via fetch
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(data => {
console.log('Data received:', data);
})
.catch(error => {
console.error('Request error:', error);
});
// Adding a microtask
queueMicrotask(() => {
console.log('Executing microtask');
});
// Setting a timer with a delay of 0 milliseconds
setTimeout(() => {
console.log('Executing macrotask (setTimeout)');
}, 0);
console.log('End of script');
1. `Script start`
2. `End of script`
3. `Fetch call`
4. `Executing microtask (queueMicrotask)`
5. `Executing microtask (setTimeout)`
6. `Microtask then(response => response.json())`
7. `Microtask then(data => ...)`
8. `Miscotask catch(error => ...)`
Here we can see that, even though fetch was called before the macrotask, the result of the request was processed after the macrotask was executed
##Conclusion
In this article, we explored the differences between macrotasks and microtasks, and also touched a little on page rendering. Understanding the differences in tasks and their priorities helps developers create more performant, responsive, and reliable web apps, as well as effectively manage asynchronous operations and events.