🧠 Unraveling Macrotasks and Microtasks in JavaScript: What Every Web Developer Should Know 💯

Marsel Akhmetshin - Apr 24 - - Dev Community

Event Loop

Let's remember how the web browser event loop works. Event Loop consists of three main components:

  1. JavaScript engine – a component that executes JavaScript code and provides access to the Web API
  2. Web APIs – browser interfaces such as fetch, timers, and other functions
  3. Event queue – a data structure that accumulates UI events and Web API-initiated events, such as fetch execution results Image description

It goes like this:

  1. Code Execution: First of all, the synchronous code on the call stack is executed until the Stack is emptied
  2. 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
  3. **Pushing Events onto the Stack: **If there are pending tasks in the Event Queue, Event Loop pushes them onto the call stack for execution
  4. 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:

Image description

  1. First, we execute the fetch request
  2. This task is moved from the stack to the Web API
  3. Next, the browser executes the request and returns the execution results to the queue, in some of its internal representation
  4. 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
  • 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:

  1. 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.
  2. 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 through queueMicrotask 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');


Enter fullscreen mode Exit fullscreen mode

In this example, the output order will be as follows:

  1. Start
  2. End
  3. Macrotask completed Even if setTimeout 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');


Enter fullscreen mode Exit fullscreen mode

In this case, the execution order will look like this:

  1. Start
  2. End
  3. 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');


Enter fullscreen mode Exit fullscreen mode

The execution order will be as follows:

  1. Start
  2. End
  3. Microtask completed
  4. Microtask 2 completed
  5. 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');


Enter fullscreen mode Exit fullscreen mode

The output order will be as follows:

  1. Synchronized start
  2. Synchronized end
  3. Microtask 1: first promise completed
  4. Microtask 2: second promise completed
  5. First requestAnimationFrame: animation update
  6. Second requestAnimationFrame: animation update
  7. Macrotask 1: setTimeout
  8. 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');


Enter fullscreen mode Exit fullscreen mode

The output order will be as follows:

  1. Script start
  2. End of script
  3. Microtask: processing the first promise
  4. Macrotask: setTimeout
  5. 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);


Enter fullscreen mode Exit fullscreen mode

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 via setTimeout. 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);
}


Enter fullscreen mode Exit fullscreen mode

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 execution

  • Heavy task is finished! - 15 macro tasks completed

  • frame 2 - 104ms have passed since the last rAF

  • rAF from a macrotask - caused by 15 rAF with an interval of 0ms

  • Heavy task is finished!- 18 macro tasks completed

  • frame 3 - 105ms have passed since the last rAF

  • rAF from a macrotask - caused by 18 rAF with an interval of 0ms

  • Heavy task is finished! - 16 macro tasks completed

  • frame 4 - 102ms have passed since the last rAF

  • rAF from a macrotask - caused by 16 rAF with an interval of 0ms

  • Heavy task is finished! - 19 macro tasks completed

  • frame 5 - 102ms have passed since the last rAF

  • rAF from a macrotask - caused by 19 rAF with an interval of 0ms

  • Heavy task is finished! - 20 macro tasks completed

  • frame 6 - 104ms have passed since the last rAF

  • rAF from a macrotask - caused by 20 rAF with an interval of 0ms

  • Heavy task is finished! - 12 macro tasks completed

  • frame 7 - 64ms have passed since the last rAF

  • rAF from a macrotask - caused by 12 rAF with an interval of 0ms

  • frame 8 - 0ms has passed since the last rAF

  • frame 9 - 13ms have passed since the last rAF

  • frame 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 to requestAnimationFrame 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);
  });
});


Enter fullscreen mode Exit fullscreen mode

The output order will be as follows:

  1. Data loaded
  2. Settings loaded
  3. User data loaded
  4. Animation for: Data loaded 9516312.2
  5. Animation for: Settings loaded 9516312.2
  6. 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 use fetch 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.
Enter fullscreen mode Exit fullscreen mode
. . . . .