JavaScript runtimes use an event loop to run their workload, specifically to execute the code, collect and process events, and perform queued sub-tasks. Both Browser runtimes and Node.js use event loops but in somewhat different ways.
Read below, and you’ll find out when you should use microtasks and when you should simply stick to macrotasks with event loops.
JavaScript’s Event Loop Implementation
Like many other runtimes, JS runtimes employ an event loop to execute the code. In JS lingo, the runtime is processing messages in the event loop. The concept of an event loop is pretty simple:
- tasks (messages in this case) are added to a queue;
- the event loop takes care of processing tasks (messages) from the queue.
JavaScript implementation of an event loop is very straightforward:
- the following events are adding messages to the event loop:
- keyboard/mouse input
- setTimeout invocation
- I/O operations – usually socket ops
- the runtime picks the first task (message) available and executes it
- then it picks the next one and so on
Run to completion
Run to completion is an implementation detail of JavasScript runtimes that has interesting properties:
- a started task is executed until the end
- no other task can interrupt the current running one
- no concurrency problems can occur
Run to completion is the main reason why a long running script can block rendering of the UI. The rendering process needs to wait until the task has completed and the queue(s) are empty.
You can read more about run to completion straight on MDN Web Docs.
The Microtask and Macrotask Queue
Inside the Event Loop there are (at least) 2 different queue(s):
- the Microtask Queue
- the Macrotask Queue
The names have nothing to do on how much a task should take. Messages in both Microtask and Macrotask Queues can take as long as needed.
How are Microtask and Macrotask Different?
There are a o couple of reasons why the 2 queues differ.
- what events add message to them
- the way the event loop parses messages
The ways you can create macrotasks are:
- script tags in the DOM
- keyboard/mouse events
- setTimeout/setInterval
With microtasks, you have the following options:
- Settled promises
- MutationObserver
- queueMicrotask
But the most important difference between microtask and macrotasks lies in how the queues are processed, which includes these steps:
- the event loop picks one task from the Macrotask
- the task finishes (run-to-completion)
- the first task from the Microtask Q gets to run
- the next task from the Microtask Q gets to run
- the Microtask Q is empty so the event loop proceeds with the next part
One thing that both have in common is that when adding a new message to the Queue(s), the processing of the message will happen at some point in the future. This means that there is a delay between adding a message to a queue and the actual processing of it.
How to Handle Tasks Easier
The first application would be splitting a long running task. If you have a very long running task, it will block the rendering. The main reason for this is the run-to-completion feature.
A solution to this is to split the long running task in smaller chunks. A simple way of doing this is to use setTimeout (a timeout of 0 is ok) to split the chunks of work. The simple fact that you schedule a macrotask gives time for other tasks to run.
In this case, the event loop will get to run the renderer with the help of this short function:
function longRunningTask () {
while(condition) {
// do compute intensive ops
// this will block the renderer
}
}
You can try to rewrite this to take advantage of the queueing algorithms:
const currentBatchId;
function longRunningTaskWithId (id) {
const runningTime = 0;
while(condition && runningTime < MAX_RUNING_TIME) {
// update runningTime so we can measure the time spent here
}
// schedule a new running task
if (!condition) {
// update currentBatchId
setTimeout(() => longRunningTaskWithId(currentBatchId), 0)
}
}
Another option is to run a piece of code after all event handlers have run. A mouse/keyboard is a macrotask, so it’ll run to completion. The scheduled microtask/macrotask will run after the current event loop gets a chance.
onClick = () => {
// do some work
(new Promise((res, rej) => {
res(true)
})
).then(val => {
// this will run after the event bubbles up and
// all the event handlers are run
})
}
What Are the Caveats?
There are a few things to be aware of when thinking about the queues:
- you can schedule new microtask from inside a microtask
- this could lead to starvation, as you could grow the microtask Q indefinitely
- in practice, both Node.js and Chrome impose hard limits on how many task they run
How does Microtask and Macrotask Fit in With Some JS Semantics
Now that you have an idea on how queues work, let’s dig deeper into a few implementation details of JS primitives.
- A promise is never run in sync even if it get’s resolved right away
The reason is that the promise will get schedules in another queue (if coming from a macrotask) or in the same queue, but at the end. This is the main mechanism that ensures always async for promises.
- The timeout specified for setTimeout and setInterval is the minimum time
The interval specified in setTimeout and setInterval is the time between the current time and future times when the callback will be added to the queue. Because there could be other tasks in the macrotask queue, and because of the dynamic nature of the microtask queue, there’s no guarantee on the time to process the callback.
Key Things to Remember
I hope you got a better understanding of how the JS event loop works. This should help you with some optimization tricks for CPU intensive tasks. One example is that if you keep queuing tasks in the microtask queue forever, your browser will become unresponsive.
A few things you need to remember are:
- If you have long-running tasks, splitting them in smaller chunks and scheduling a mico/macrotask will improve the UX
- Using a microtask will ensure stability of state as input events and layout can only change during a macrotask
I also hope now it makes sense for you how some JS implementation work and that this knowledge will help you in your future projects.