How to understand macrotasks in JavaScript

How to understand macrotasks in JavaScript

Macrotasks are the main units of work the event loop picks up one at a time. When you schedule something with setTimeout, setInterval, or even events like click, you’re queuing a macrotask. The event loop processes these sequentially: it takes a macrotask from the queue, runs it to completion, then checks if there’s anything else to do before moving on.

What makes macrotasks a bit tricky is how they relate to microtasks. Microtasks—actions like Promise resolutions or MutationObserver callbacks—always run right after the current macrotask finishes, before the event loop picks up the next macrotask. This means microtasks are kind of “inside” macrotasks, a way to get additional work done immediately once the current job completes.

The result is a subtle but crucial ordering guarantee: while macrotasks are executed in the order they’re queued, microtasks generated during any macrotask run first, even if a later macrotask is waiting. This helps you fine-tune responsiveness but also traps naive coders who expect simple first-in-first-out behavior across all async callbacks.

To clarify, here’s a simplified view of the event loop’s cycle:

while (true) {
  // Take the next macrotask off the queue and run it
  let macrotask = macrotaskQueue.shift();
  macrotask();

  // Run all queued microtasks before picking up the next macrotask
  while (microtaskQueue.length > 0) {
    let microtask = microtaskQueue.shift();
    microtask();
  }
}

The critical part of this sequence is that microtasks can queue more microtasks inside their execution, so that inner loop runs until the microtask queue empties. This behavior is why promises chained within a then callback run immediately after, postponing any macrotasks that might have been scheduled.

Without this division, the event loop wouldn’t have a reliable way to handle operations needing immediate follow-up but not guaranteed immediate execution. Macrotasks serve as the broader scheduling mechanism for user-triggered or time-based events, while microtasks offer a mechanism for continually flowing execution without yielding control back to the browser or Node.js environment prematurely.

In terms of actual thread work, JavaScript is still single-threaded in browsers—it only does one thing at once. Macrotasks mirror higher-level jobs, like responding to user input or network events, while microtasks act like internal chains of work spun off from those macrotasks, ensuring fine-grained sequencing without interruptions.

Here’s a tiny piece of code that illustrates how macrotasks and microtasks intermingle:

console.log('script start');

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

Promise.resolve()
  .then(() => {
    console.log('promise microtask 1');
  })
  .then(() => {
    console.log('promise microtask 2');
  });

console.log('script end');

The output will consistently be:

script start
script end
promise microtask 1
promise microtask 2
setTimeout macrotask

Notice how both microtasks run right after the main script finishes, before any macrotask triggers, even with a zero-millisecond delay.

This delay is a key property: scheduling a macrotask doesn’t mean immediate execution, it means “run after the current macrotask and all microtasks.” Understanding this well unlocks good intuition for async debugging, performance tuning, and often unexpected timing bugs.

The event loop’s design revolves around these macrotasks for reasons that go deeper into both browser rendering cycles and the host environment’s constraints: macrotasks let browsers yield between user inputs, repaint the screen, and do housekeeping, all without letting script monopolize time. When macrotasks get too long, your UI freezes. When they are broken into smaller chunks mixed with microtasks, you can keep things snappy.

In other words, macrotasks are the skeleton of the event loop’s pacing, the spine around which asynchronous JS scheduling is built. Microtasks fill in the muscle, letting you chain nuanced, immediate follow-ups inside that frame.

To really master async behavior, start thinking of macrotasks as “jobs” handed to the event loop, with each job allowed to generate many microtasks as subtasks. When a job is done and its microtasks drained, you get a chance to handle the next job, update the UI, and repeat. This mindset reveals why so many async hacks revolve around managing when code lands inside microtasks versus macrotasks—because it controls when state changes become visible to the rest of the system.

How macrotasks shape asynchronous behavior in practice

This separation also explains why certain timing functions don’t behave exactly as you might expect. For example, setTimeout(() => {}, 0) queues a macrotask that can only run after the current macrotask and all its microtasks complete. Meanwhile, Promise.resolve().then(...) schedules microtasks that run immediately after the current macrotask. The macrotask queue enables the environment to insert pauses between batches of JavaScript execution, allowing it to perform other internal duties.

Consider how user input events are naturally modeled as macrotasks. The browser waits for your script to finish processing the previous task and all its microtasks before invoking a handler for the next click or keypress. This means if your script runs long computations inside a macrotask, the UI can freeze because no new macrotasks get a chance to start until yours ends.

Here’s an example that shows how a heavy macrotask can block subsequent macrotasks:

console.log('start');

setTimeout(() => {
  console.log('macrotask 1 start');
  const start = Date.now();
  while (Date.now() - start < 200) {
    // Block event loop - simulating heavy work
  }
  console.log('macrotask 1 end');
}, 0);

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

console.log('end');

Expected console output:

start
end
macrotask 1 start
macrotask 1 end
macrotask 2

Even though both setTimeout callbacks are scheduled with zero delay, the second macrotask cannot run until the first finishes. That heavy synchronous loop inside the first macrotask completely blocks the event loop and delays all subsequent processing.

On the other hand, microtasks let you insert tiny completions between these larger chunks, useful for chaining promises and reacting to async completions quickly without losing the opportunity for user interaction or rendering. They run too quickly to provide any effective pause to the environment – instead, they ensure logic completion before any macrotask begins.

To see the interplay, add some microtasks inside the macrotasks:

console.log('start');

setTimeout(() => {
  console.log('macrotask 1 start');

  Promise.resolve().then(() => {
    console.log('microtask inside macrotask 1');
  });

  console.log('macrotask 1 end');
}, 0);

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

console.log('end');

Output:

start
end
macrotask 1 start
macrotask 1 end
microtask inside macrotask 1
macrotask 2

Notice how the microtask inside the first macrotask runs immediately after its synchronous code, but before the next macrotask begins. This immediate execution between macrotasks is what allows microtasks to encapsulate precise async sequences, rather than deferring to the far-off next turn of the event loop.

Understanding this model also clarifies why promise rejection handlers and mutation observers feel so “immediate” compared to timers and events. They’re running in that microtask phase, guaranteeable before the environment checks for input or renders anything.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

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