JavaScript queueMicrotask: Scheduling Microtasks

JavaScript's single-threaded execution model relies on an event loop that processes tasks from different queues. Understanding this model is crucial for writing performant, predictable code.

Key Insights

  • queueMicrotask() schedules callbacks to run after the current task completes but before control returns to the event loop, making it faster than setTimeout() and more explicit than Promise.resolve().then()
  • Microtasks execute in FIFO order and all queued microtasks run to completion before the browser renders or processes the next macrotask, making them ideal for batching operations
  • Misusing queueMicrotask() can starve the event loop—always ensure microtasks eventually complete and avoid creating infinite microtask loops that block rendering

Introduction to the Event Loop and Task Queues

JavaScript’s single-threaded execution model relies on an event loop that processes tasks from different queues. Understanding this model is crucial for writing performant, predictable code.

The event loop operates with two primary queue types: macrotasks (also called tasks) and microtasks. Macrotasks include setTimeout, setInterval, I/O operations, and UI rendering. Microtasks include Promise callbacks, MutationObserver callbacks, and—our focus today—callbacks scheduled with queueMicrotask().

The execution order follows a specific pattern: synchronous code runs first, then all queued microtasks execute in order, and finally the event loop picks the next macrotask. This cycle repeats continuously.

console.log('1: Synchronous');

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

Promise.resolve().then(() => {
  console.log('3: Microtask (Promise)');
});

queueMicrotask(() => {
  console.log('4: Microtask (queueMicrotask)');
});

console.log('5: Synchronous');

// Output:
// 1: Synchronous
// 5: Synchronous
// 3: Microtask (Promise)
// 4: Microtask (queueMicrotask)
// 2: Macrotask (setTimeout)

Notice how both microtasks execute before the setTimeout callback, even though setTimeout was scheduled first. This demonstrates the priority microtasks receive in the event loop.

What is queueMicrotask()?

queueMicrotask() is a modern JavaScript API that explicitly schedules a function to execute as a microtask. It accepts a single callback function and returns undefined.

queueMicrotask(callback);

Introduced in 2018 and standardized across browsers by 2019, queueMicrotask() provides a straightforward way to schedule microtasks without the indirection of creating a Promise. It’s supported in all modern browsers (Chrome 71+, Firefox 69+, Safari 12.1+) and Node.js 11+.

Here’s a basic example demonstrating execution order:

console.log('Start');

queueMicrotask(() => {
  console.log('Microtask 1');
});

queueMicrotask(() => {
  console.log('Microtask 2');
});

console.log('End');

// Output:
// Start
// End
// Microtask 1
// Microtask 2

The synchronous code completes first, then both microtasks execute in the order they were queued. This predictable FIFO (first-in, first-out) behavior makes microtasks reliable for sequencing operations.

Microtask Execution Order

The microtask queue operates with strict ordering guarantees. When the JavaScript engine reaches a microtask checkpoint (typically after the current task completes), it processes all queued microtasks before continuing.

Critically, if a microtask queues additional microtasks, those new microtasks execute in the same checkpoint. This differs from macrotasks, where new macrotasks join the back of the queue for future event loop iterations.

console.log('Script start');

setTimeout(() => console.log('setTimeout 1'), 0);

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

queueMicrotask(() => {
  console.log('queueMicrotask 1');
  Promise.resolve().then(() => console.log('Nested Promise'));
});

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

console.log('Script end');

// Output:
// Script start
// Script end
// Promise 1
// queueMicrotask 1
// Nested microtask
// Nested Promise
// Promise 2
// setTimeout 1
// setTimeout 2

This example illustrates several key points: all microtasks run before any macrotasks, nested microtasks execute within the same checkpoint, and the order within microtasks respects their queue position.

Practical Use Cases

Batching DOM Updates

One of the most powerful uses for queueMicrotask() is batching multiple state changes into a single DOM update, reducing reflows and improving performance:

class StateManager {
  constructor() {
    this.state = {};
    this.updateScheduled = false;
    this.listeners = [];
  }

  setState(updates) {
    Object.assign(this.state, updates);
    
    if (!this.updateScheduled) {
      this.updateScheduled = true;
      queueMicrotask(() => {
        this.flush();
      });
    }
  }

  flush() {
    this.updateScheduled = false;
    const currentState = { ...this.state };
    
    // Batch all DOM updates together
    this.listeners.forEach(listener => listener(currentState));
  }

  subscribe(listener) {
    this.listeners.push(listener);
  }
}

// Usage
const manager = new StateManager();
manager.subscribe(state => {
  document.getElementById('display').textContent = 
    `Count: ${state.count}, Name: ${state.name}`;
});

// Multiple setState calls batch into one DOM update
manager.setState({ count: 1 });
manager.setState({ name: 'Alice' });
manager.setState({ count: 2 });

// Only one DOM update occurs, with final state: { count: 2, name: 'Alice' }

This pattern ensures that rapid state changes don’t trigger multiple expensive DOM operations. The microtask runs after all synchronous code completes but before the browser renders.

Deferring Work

Use queueMicrotask() to defer non-critical work until after the current execution context completes:

function processWithCleanup(data) {
  const result = expensiveOperation(data);
  
  // Defer cleanup until after result is returned
  queueMicrotask(() => {
    cleanupTemporaryResources();
    logAnalytics('operation_complete');
  });
  
  return result;
}

This keeps the critical path fast while ensuring cleanup happens before the next macrotask.

Common Pitfalls and Best Practices

The Infinite Microtask Loop

The most dangerous pitfall is creating an infinite microtask loop that starves the event loop:

// DON'T DO THIS - Infinite loop!
function infiniteMicrotasks() {
  queueMicrotask(() => {
    console.log('Running...');
    infiniteMicrotasks(); // Queues another microtask
  });
}

infiniteMicrotasks(); // Browser becomes unresponsive

Because microtasks run to completion before the event loop continues, this blocks all rendering and user interaction. Always ensure microtasks eventually terminate:

// CORRECT - Bounded execution
function boundedMicrotasks(count = 0, max = 100) {
  if (count >= max) return;
  
  queueMicrotask(() => {
    console.log(`Iteration ${count}`);
    boundedMicrotasks(count + 1, max);
  });
}

boundedMicrotasks(); // Safely completes after 100 iterations

Performance Considerations

While microtasks are lightweight, excessive microtask scheduling can delay rendering. If you’re scheduling hundreds of microtasks, consider:

  1. Batching operations more aggressively
  2. Using requestAnimationFrame for visual updates
  3. Breaking work into chunks with setTimeout for long-running operations

When NOT to Use queueMicrotask

Avoid queueMicrotask() for:

  • Long-running computations: Use setTimeout or Web Workers to avoid blocking
  • Visual updates: Use requestAnimationFrame for smoother animations
  • User interaction responses: Execute synchronously for immediate feedback

queueMicrotask vs Alternatives

Different async scheduling APIs have distinct timing characteristics:

console.log('1: Sync start');

setTimeout(() => console.log('2: setTimeout'), 0);

queueMicrotask(() => console.log('3: queueMicrotask'));

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

// Node.js only
if (typeof process !== 'undefined') {
  process.nextTick(() => console.log('5: process.nextTick'));
}

console.log('6: Sync end');

// Browser output:
// 1: Sync start
// 6: Sync end
// 3: queueMicrotask
// 4: Promise.then
// 2: setTimeout

// Node.js output:
// 1: Sync start
// 6: Sync end
// 5: process.nextTick
// 3: queueMicrotask
// 4: Promise.then
// 2: setTimeout

Key differences:

  • queueMicrotask(): Direct, explicit microtask scheduling. Clearest intent.
  • Promise.resolve().then(): Also schedules a microtask but requires Promise machinery. Use when you’re already working with Promises.
  • process.nextTick() (Node.js): Runs before microtasks. Highest priority but Node-specific.
  • setTimeout(fn, 0): Schedules a macrotask. Runs after microtasks and has a minimum 4ms delay in browsers for nested calls.

For cross-platform code prioritizing clarity, queueMicrotask() is the best choice. It’s explicit, standardized, and performs identically across environments (unlike process.nextTick).

Conclusion

queueMicrotask() fills an important gap in JavaScript’s async toolkit. It provides explicit, performant microtask scheduling without the overhead of Promises or the delays of setTimeout.

Use it when you need to defer work until the current execution completes but want it to run before rendering or the next macrotask. It’s particularly valuable for batching operations, maintaining consistent execution order, and implementing low-level async patterns.

Remember the golden rules: keep microtasks short, ensure they terminate, and understand that they block everything else until completion. Master these principles, and queueMicrotask() becomes a powerful tool for writing responsive, efficient JavaScript applications.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.