Microtasks and Macrotasks in Event Loop: A Comprehensive Guide

Microtasks and Macrotasks in Event Loop: A Comprehensive Guide

Introduction

The Node.js event loop is at the core of what makes Node tick. This asynchronous, non-blocking architecture is critical to the performance and scalability of Node.js. But the event loop’s inner workings can be mysterious to newcomers. Two key concepts – the microtask queue and macrotask queue – are particularly important to understand.

In this comprehensive guide, we will demystify microtasks and macrotasks in Node.js. You will learn how these queues power the event loop, how to use them properly in your code, and tools to visualize their behavior. Let’s get started!

Introduction to the Node.js Event Loop

The event loop is the secret sauce that makes Node.js so performant compared to traditional blocking server architectures. Here’s how it works at a high level:

  • Node uses a single thread to handle all requests and events concurrently.
  • Long running or blocking operations are delegated to worker threads to free up the main thread.
  • An event loop continually checks a queue for incoming requests and processes them.

This non-blocking approach avoids wasted CPU cycles and slowdowns from context switching between threads. The event loop can juggle thousands of concurrent connections with minimal overheads.

But there’s more complexity under the hood. The event loop maintains separate queues for different types of operations. We’ll focus on two critical queues – the microtask queue and macrotask queue. Understanding how these queues interact is key to mastering the event loop.

Microtasks and Microtasks Queue

Microtasks are lightweight async operations that can be executed quickly after the current function completes but before the event loop continues. They are queued separately from macrotasks.

Some common examples of microtasks:

  • Promise callbacks
  • Mutation Observer callbacks
  • QueueMicrotask() calls

Microtasks are often used to execute follow-up logic after some async operation completes. For example:

// Fetch user from API
fetch('/user')
  .then(user => {
    // Microtask to process user
  })

The .then() callback is added as a microtask once the fetch promise resolves. Other microtasks are also added to the microtask queue.

The key difference versus macrotasks is that the microtask queue has priority. The event loop always empties the microtask queue before picking up the next macrotask.

How the Microtask Queue is Processed

Whenever a microtask is added to the queue, this is what happens:

  1. The current function finishes execution. This may add more microtasks.
  2. The event loop enters the microtask checkpoint.
  3. All queued microtasks execute sequentially until the queue is empty.
  4. The event loop exits the microtask checkpoint and continues.

Processing microtasks between macrotasks ensures related logic executes immediately without lag. For example:

console.log('Hi'); // 1

setTimeout(() => {
  console.log('Callback'); // 5  
}, 0);

Promise.resolve()
  .then(() => console.log('Promise')); // 3

console.log('Bye'); // 2

Even though the timer delay is 0 ms, the promise microtask runs first to complete the current phase.

Properly leveraging microtasks is key to understanding the event loop flow and avoiding bugs.

Common Sources of Microtasks

Here are some common ways microtasks get added to the queue:

  • Promise resolution – .then() and .catch() callbacks enqueue microtasks. Chained promises can add multiple microtasks.
  • MutationObserver – Browser API for observing DOM changes. Its callbacks are microtasks.
  • Object.observe – Provides callbacks on object property changes but is deprecated.
  • process.nextTick() – Node.js utility to enqueue a callback as a microtask. Does not delay work like setTimeout(fn, 0).
  • queueMicrotask() – Standard way to queue a microtask from ECMAScript. Replaces process.nextTick() in Node.js.
  • Generators – Generator.prototype.return() and .throw() methods create microtasks when closing generators.

Using these mechanisms properly is important to understand event loop ordering. Let’s contrast microtasks with macrotasks.

Macrotasks and the Macrotask Queue

Macrotasks represent larger chunks of asynchronous logic like I/O or UI rendering. These tasks are queued separately from microtasks.

Some common examples:

  • Timers – setTimeout(), setInterval()
  • IO Callbacks – fs readFile(), request() callback
  • UI Rendering – React updates
  • setImmediate()

Macrotasks execute after each iteration through the event loop. The loop runs as follows:

  1. Execute current function until completion (can enqueue microtasks)
  2. Process all microtasks
  3. Dequeue next macrotask and start execution
  4. Repeat

This cycle ensures microtasks always interrupt macrotasks but not vice-versa. For example:

setTimeout(() => {
  console.log('Timeout');
}, 0);

Promise.resolve()
  .then(() => console.log('Promise'));

// Prints:
// Promise
// Timeout

The promise microtask preempts the timer macrotask, even though the timer has 0ms delay.

Macrotasks queue useful logic like network events, data writes, UI updates etc. Properly coordinating macrotasks and microtasks is key to high-performance Node.js code.

Tools for Visualizing the Event Loop

To build a solid mental model of the event loop, it helps to visualize how the microtask and macrotask queues are processed over time. Here are some useful tools:

  • Loupe – App for visualizing promise chains and event loop ordering. Helpful for debugging.
  • Promisees – Interactive playground showing promise resolutions. Illustrates queuing and chaining.
  • Animate Promises – Visualizer by Jake Archibald showing async code flow. Great learning tool.
  • Async Scope Visualizer – Chrome DevTools extension that visualizes promises.

These tools create a timeline of tasks and events so you can see how code runs in relation to the event loop. Very handy for debugging complex flows.

Common Event Loop Mistakes

Here are some common mistakes related to microtasks and macrotasks:

  • Blocking the main thread – Synchronous CPU-intensive work blocks event loop. Use worker threads.
  • Queueing too many microtasks – Can starve macrotasks if millions of microtasks enqueue.
  • Unhandled promise rejections – Causes exceptions. Always handle errors.
  • Calling fs/IO synchronously – Use non-blocking async versions.
  • Unnecessary timers – Avoid setTimeout(fn, 0). Use a microtask instead.
  • Race conditions – Microtasks executing too early before macrotask logic.

With practice visualizing the event loop, you’ll be able to avoid these pitfalls.

Tips for Optimizing Your Event Loop Code

Here are some tips for writing optimal code with the event loop:

  • Prefer microtasks for follow-up logic after async operations. Use queueMicrotask()
  • Try to make async operations non-blocking wherever possible.
  • Use worker threads for CPU-intensive work to free up event loop.
  • Batch similar microtasks into chains to avoid queue buildup.
  • Avoid too many small microtasks – consolidate logic where possible.
  • Use async loops rather than synchronous loops if iterating over async results.
  • Handle errors properly to avoid uncaught exceptions.
  • Visualize your event loop flow to detect issues early.

Microtasks and Macrotasks Comparison

Here is a comparison table of microtasks vs macrotasks in Node.js:

MicrotasksMacrotasks
ExamplesPromise callbacks, MutationObserver callbacks, queueMicrotask()SetTimeout, setInterval, setImmediate(), I/O callbacks, UI rendering
Queue TypeMicrotask queueMacrotask queue
PriorityHigh, executed firstLow, executed after microtasks
ConcurrencySequentialParallel
Use CasesFollow-up tasks after async logicLarger asynchronous operations
Limits4-5 million microtasks queuedUnlimited
Unhandled ErrorsCrash Node.js processCrash Node.js process
microtasks vs macrotasks

In summary:

  • Microtasks queue separately and have higher priority than macrotasks
  • Microtasks are used for follow up logic, macrotasks for larger async operations
  • Microtasks are limited to avoid starving macrotasks
  • Unhandled errors in either queue will crash the Node.js process

Properly using microtasks and macrotasks is key to understanding the Node.js event loop flow and writing optimal asynchronous code.

Conclusion

The Node.js event loop enables incredibly high performance and throughput by coordinating events, I/O, and asynchronous logic. Mastering the differences between microtasks and macrotasks unlocks the ability to craft speedy and reliable Node.js applications.

There’s always more to learn when it comes to the event loop intricacies. But understanding these core microtask and macrotask concepts will give you the confidence to build complex, real-time apps in Node.js without hiccups.

The next time you find yourself dealing with race conditions, inconsistent state, or blocking code, remember to rely on your trusty microtask and macrotask mental model. Pair it with great tools and diagnostics, and you’ll be able to visualize and optimize your Node.js event loops like a pro!

Frequently Asked Questions

Here are some common questions about microtasks and macrotasks in Node.js:

What are some examples of microtasks and macrotasks?

Common microtasks include promise callbacks, MutationObserver callbacks, and process.nextTick(). Macrotasks include setTimeout, setInterval, setImmediate, I/O callbacks, and UI rendering.

How are microtasks and macrotasks queued differently?

Microtasks queue in the microtask queue, macrotasks queue in the macrotask queue. The event loop clears the microtask queue between each macrotask.

Why are microtasks executed before macrotasks?

This ensures microtasks complete and update state before the next macrotask relies on that state. It prevents bugs from race conditions.

What is the difference between process.nextTick() and setImmediate()?

process.nextTick() enqueues a microtask, setImmediate() enqueues a macrotask. Microtasks occur before macrotasks so nextTick() has higher priority.

How can I visualize the Node.js event loop?

Debugging tools like Loupe, Promisees, and the Async Scope Visualizer in Chrome DevTools help visualize the ordering of microtasks, macrotasks and events in the loop.

What are some common event loop mistakes to avoid?

Blocking the main thread, queueing too many microtasks, unhandled promise rejections, synchronous I/O, unnecessary timers, and race conditions are common issues that hurt event loop performance.

Should I use microtasks or macrotasks for async logic?

Prefer microtasks for follow-up after an async operation completes. Use macrotasks for larger asynchronous operations like I/O.

How many microtasks can be queued at once?

Node.js imposes a maximum of around 4-5 million microtasks to prevent starving macrotasks. Batch similar microtasks to avoid hitting the limit.

What happens if a microtask or macrotask has an unhandled exception?

Uncaught errors and promise rejections will crash the Node.js process. Always handle errors properly.

Leave a Reply

Your email address will not be published. Required fields are marked *