[Frontend Note] What are macrotasks and microtasks in the EventLoop

Use macrotasks and microtasks to fully understand asynchronous behavior in Javascript

Posted by Jamie on Monday, October 31, 2022

What are macrotasks and microtasks? The difference between Promise and setTimeout

Preface

When asynchronous behavior is mentioned in Javascript, names like setTimeout, Promise, and async/await immediately come to mind. However, while learning about V8 and the browser recently, I realized that although these three are all asynchronous on a macro level, they are actually quite different on a micro level.

Task Queue and Event Loop (MacroTask Queue / Event Loop)

When a piece of Javascript code is invoked, V8 sequentially pushes tasks onto the MacroTask Queue. The Event Loop continuously checks whether the queue is empty; if it is not empty, following the FIFO (first-in-first-out) principle of the MacroTask Queue, it takes a task out and puts it on the main thread for execution.

image

(image from: https://javascript.info/event-loop)

The tasks in the MacroTask Queue are called macrotasks.

When does setTimeout fire

The way setTimeout works is: after a certain amount of time, push a callback into the MacroTask Queue. Suppose we have a function:

function foo(){
	setTimeout(()=>{
		console.log("setTimeout task")
	},1000)
}

Under the V8 engine, the execution order is:

  1. The foo function waits in the MacroTask Queue to be executed.
  2. After the macrotasks ahead of it in the queue have executed, foo enters the Event Loop and is pushed onto the call stack.
  3. V8 executes the foo function and creates an execution context. When it hits setTimeout, it wraps the callback inside as a macrotask and pushes it back onto the MacroTask Queue. (We are skipping the DelayedIncomingQueue part here—in short, there is another queue that handles timer tasks, and once the time is up the task is pushed onto the MacroTask Queue.)
  4. After the previous macrotasks have all finished, the callback is then executed.

Using setTimeout to solve stack overflow

In many interview questions, you can see solutions that use setTimeout to solve stack overflow problems. The principle is to take a script that would otherwise be pushed onto the stack and re-run it as a macrotask instead.

For example:

  • This piece of code causes stack overflow:

    function runStack (n) {
    if (n === 0) return 100;
    return runStack( n- 2);
    }
    runStack(50000)
    
  • But using setTimeout to keep the task off the stack solves the stack overflow:

    function runStack (n) {
    if (n === 0) return 100;
    return setTimeout(()=>{runStack( n- 2)},0);
    }
    runStack(50000)
    

But this is actually treating the symptom rather than the cause. Turning a synchronous task into an asynchronous one easily causes performance issues, because it interferes with the execution of subsequent macrotasks.

The time set by setTimeout is not exactly the actual execution time

This is because V8 only wraps the callback into a macrotask and pushes it onto the MacroTask Queue after the delay set by setTimeout has passed. And if the macrotasks ahead of it in the MacroTask Queue take a long time, the execution timing of the setTimeout callback will be affected.

When does a Promise execute

As mentioned above, because the granularity of macrotasks is too coarse, it is hard to control the timing of execution. For tasks that need to respond in real time, macrotasks are not very suitable.

So, on top of the MacroTask Queue and macrotasks, the microtask is introduced. When V8 executes a piece of Javascript, it creates a Global Execution Context for it, and at the same time V8 also internally creates a microtask queue (Micro Task Queue). In other words, every macrotask has a microtask queue of its own.

In modern browsers, there are two ways to produce a microtask:

  • Use a MutationObserver to monitor a DOM node, and use Javascript to modify any behavior of that DOM
  • The resolve and reject callbacks inside a Promise

So why does Promise have anything to do with microtasks? This brings us to why we need Promise in the first place. With the native XMLHttpRequest, if we want to call one api after another, then handle business logic, and on top of that handle errors for each request, it easily leads to callback hell and the code becomes very hard to read. Promise solves these problems through deferred binding of callback functions, the ability for the return value of a callback function to pass through, and exception bubbling. (I will write another post on “Implementing Promise by Hand” later.)

Among these, the part of Promise that defers binding of callback functions is solved through microtasks.

So, after wrapping an XMLHttpRequest with a Promise, how does it execute?

  • Inside the Promise’s executor function, calling XMLHttpRequest triggers a macrotask.
  • The request initiated by XMLHttpRequest is executed by the Network Process, which then sends the result back to the Render Process via IPC, and the Render Process pushes it onto the MacroTask Queue.
  • If the XMLHttpRequest succeeds, the resolve microtask is triggered inside that macrotask; if the request fails, the reject microtask is triggered.

Async/Await

As for how async/await turns asynchronous Promises into synchronous-looking code, this requires us to talk about coroutines. On a single thread there can be many coroutines, but only one coroutine can hold the control of the main thread at a time. (Generators in Javascript are also implemented based on coroutines.)

So how does async/await use coroutines? Take the following code as an example:


async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)
  • First, console.log(0) is executed.
  • Then the foo function is processed. When Javascript handles an async function, it first records the call stack of that async function, then executes console.log(1).
  • When it hits the await keyword, a Promise object is created by default.

    let promise_ = new Promise((resolve,reject){
    resolve(100)
    })
    
  • After that, Javascript pauses the current coroutine’s execution, hands the main thread control back to the parent coroutine to execute, and at the same time returns this Promise object to the parent coroutine.

  • The parent coroutine first handles console.log(3).

  • Then it checks the microtask queue and finds resolve(100). After running it, the foo coroutine is reactivated.

  • After the foo coroutine is reactivated, the value just produced is returned to a, and console.log(a) and console.log(2) are executed.

Conclusion

We can see that if we look at asynchronous tasks through the lens of macrotasks and microtasks, we can clearly order the tasks, and have a clear mental model in real-world scenarios.

ChangeLog

  • 20221031 - init
  • 20260501–translate by claude code

Ref