LEARN · DEBUGGING GUIDE

JavaScript Microtask & Promise Execution Order Debugging

JavaScript's microtask queue can cause subtle race conditions when mixing native Promises, thenables, process.nextTick, and MutationObserver. Here's how to identify and fix them.

AdvancedJavaScript6 min read

What this usually means

You're relying on a specific interleaving of microtasks between different microtask sources (native Promises, thenables, process.nextTick, queueMicrotask, MutationObserver) without understanding the queue prioritization rules. The ECMAScript spec defines a single microtask queue, but host environments like Node.js and browsers add their own microtask-like mechanisms with different scheduling. For example, process.nextTick in Node.js has its own queue that runs before Promise microtasks, while window.queueMicrotask in browsers runs interleaved with Promise callbacks. Mixing these leads to observable ordering differences. Additionally, 'thenables' (objects with a .then method) can cause recursive microtask enqueuing that changes the expected order.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1console.log('A'); Promise.resolve().then(() => console.log('B')); queueMicrotask(() => console.log('C')); process.nextTick(() => console.log('D')); // Node.js: A, D, B, C? Check order.
  • 2Add a simple counter and log timestamps (performance.now()) to all microtask sources to see the exact sequence.
  • 3Use the Event Loop Inspector in Node.js: node --trace-events-enabled app.js and analyze the trace for microtask scheduling.
  • 4In browsers, use performance.mark and performance.measure to profile microtask timing.
  • 5Isolate the issue by reducing code to the minimal microtask producer (Promise, MutationObserver, etc.) and the observer.
  • 6Check if any third-party library returns a thenable instead of a native Promise — that changes behavior.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchNode.js: --trace-warnings and process.on('warning') to catch unhandled rejections
  • searchChrome DevTools Performance tab: record and look for 'Microtask' events in the flame chart
  • searchNode.js: event loop lag / microtask queue size via perf_hooks.monitorEventLoopDelay
  • searchSource code: look for .then() chains where intermediate functions return non-Promise thenables
  • searchPolyfill or transpiler output (Babel, core-js) that may convert native Promises to thenables
  • searchMutationObserver callbacks in the browser that may fire during microtask processing
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningMixing process.nextTick with Promise microtasks assuming they run in the same queue
  • warningThird-party library returning a thenable (object with .then) instead of a native Promise, causing extra microtasks
  • warningUsing queueMicrotask inside a Promise executor, creating nested microtasks that shift order
  • warningRelying on Promise callback order when combining multiple resolved promises (they race on microtask queue)
  • warningMutationObserver callback timing varies across browsers and can interleave with Promise microtasks
  • warningTranspiled async/await (Babel regenerator) creates extra thenables that change execution order
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildStandardize on one microtask source: use only native Promises or only queueMicrotask, not both in the same logical sequence.
  • buildWrap thenables into native Promises: Promise.resolve(thenable) flattens the thenable and normalizes ordering.
  • buildIn Node.js, avoid process.nextTick for microtask ordering; use queueMicrotask or Promise.resolve().then() for consistency.
  • buildUse async/await with explicit await points to force sequential ordering instead of relying on implicit microtask ordering.
  • buildIf you must mix microtask sources, use a dedicated microtask scheduler (like an explicit queue) to control execution order.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedWrite a unit test that logs the exact sequence of microtask executions and assert the order.
  • verifiedRun the test in multiple environments: Node 14, Node 18, Chrome, Firefox, Safari.
  • verifiedUse --enable-microtask-stats in Node.js (experimental) to see microtask counts.
  • verifiedAdd a deterministic scheduler: replace Promise.resolve().then with a custom queue that processes tasks in FIFO order.
  • verifiedMonitor the performance profile to ensure no unexpected microtask bursts.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningAssuming process.nextTick and Promise microtasks share the same queue (they don't in Node.js).
  • warningBelieving that Promise.resolve().then() is the same as queueMicrotask() — they are the same in browsers but not in Node.js.
  • warningUsing MutationObserver as a microtask hack — it's not reliable and varies across browsers.
  • warningIgnoring polyfill behavior: Babel's async-to-generator uses thenables that execute differently.
  • warningTesting only in one environment and assuming the order is guaranteed by spec.
( 07 )War story

Flaky CI: Promise.then order broken after upgrading mongoose to v6

Senior Backend EngineerNode.js 14, Express, Mongoose 6, Mocha, Sinon

Timeline

  1. 09:15CI fails on test 'returns updated user after save' — intermittent
  2. 09:30Add console.log around promise chains; local tests pass
  3. 10:00Add performance.now() logging to each then callback
  4. 10:45Discover that Mongoose 6 now uses thenables internally instead of native Promises
  5. 11:00Check Mongoose changelog: v6 switched to 'mpromise' (thenable) for performance
  6. 11:20Reproduce: native Promise.then runs after thenable's then due to extra microtask
  7. 11:35Fix: wrap Mongoose calls in Promise.resolve() to flatten thenable
  8. 11:50CI passes consistently; push fix

We had a test that created a user via Mongoose, then immediately fetched it and checked a field set by a post-save hook. The test passed locally but flaked in CI about 30% of the time. I assumed a race condition with the database, but after adding extensive logging, I saw that the Promise.then callbacks were executing in different orders on different runs.

I instrumented every .then() with a timestamp and found that sometimes the then from the Mongoose save would fire before the then from my Promise.resolve().then() chain. That was weird because both were resolved promises. I checked the Mongoose source and realized v6 changed to a custom thenable (mpromise) that returns an object with .then, not a native Promise. According to the spec, thenables can cause an extra microtask when resolved via Promise.resolve().

The fix was to wrap every Mongoose call with Promise.resolve() to normalize the thenable into a native Promise. That forced the microtask to behave consistently. I also added a test that explicitly logs the order of microtasks to catch regressions. The lesson: always treat third-party 'promises' as thenables until proven otherwise.

Root cause

Mongoose 6 switched to a custom thenable (mpromise) that, when used inside Promise.resolve().then(), creates an additional microtask, causing non-deterministic ordering with other native Promise microtasks.

The fix

Wrap all Mongoose async calls in Promise.resolve() to flatten the thenable into a native Promise, ensuring consistent microtask ordering.

The lesson

Treat any non-native promise-like object as a thenable that may affect microtask ordering. Use Promise.resolve() to normalize.

( 08 )Microtask Queue Mechanics: Spec vs. Host

The ECMAScript spec defines a single queue for Jobs (microtasks). When a Promise resolves, it enqueues a PromiseReactionJob. But host environments extend this. Node.js adds the nextTick queue, which runs before the microtask queue each iteration. The browser adds the MutationObserver microtask queue, which runs interleaved with Promise jobs. This means code that relies on microtask ordering may break when ported.

For example, in Node.js: process.nextTick(() => console.log('tick')); Promise.resolve().then(() => console.log('promise')); The output is always 'tick' then 'promise' because nextTick queue drains before microtask queue. In browsers, queueMicrotask(() => console.log('qmt')) and Promise.resolve().then(...) are essentially the same queue, so order depends on enqueue order.

( 09 )Thenables: The Silent Order Breakers

A thenable is any object with a .then method. When you pass a thenable to Promise.resolve(), the spec says it must be flattened: the thenable's .then is called, which enqueues a new microtask for the eventual fulfillment. This means a thenable introduces an extra microtask boundary. If you have a chain of Promise.resolve(thenable).then(...), the thenable's internal .then runs first, then your .then runs after an extra microtask.

Example: const thenable = { then: (resolve) => resolve(1) }; Promise.resolve(thenable).then(console.log); The console.log runs after one more microtask than if you used Promise.resolve(1).then(console.log). This subtle difference can reorder callbacks when other microtasks are queued.

( 10 )Cross-Environment Consistency Strategies

To write portable code that respects microtask ordering, avoid mixing sources: use only native Promises or only queueMicrotask. If you must mix, use a custom task queue that executes in a controlled order. For async/await, the transpiler (Babel, TypeScript) may generate thenables; check the output. In Node.js, prefer async/await over raw .then() chains to reduce microtask complexity. Finally, test in all target environments early — ideally in CI containers matching production.

Frequently asked questions

Why does process.nextTick run before Promise.then in Node.js?

Node.js has two separate queues: the nextTick queue and the microtask queue. The event loop processes the nextTick queue to completion before moving to the microtask queue. This is a Node.js-specific behavior, not part of the ECMAScript spec.

Does Promise.resolve().then() always run before a setTimeout(fn, 0)?

Yes. setTimeout callbacks belong to the macrotask queue, which runs after the microtask queue is drained. So Promise.then (microtask) always executes before any macrotask, including setTimeout with 0 delay.

How can I force a specific microtask order in a test?

Use a custom microtask scheduler that queues tasks in a controlled FIFO order. Replace Promise.resolve().then with your own queue that you can flush explicitly. Alternatively, use await to create sequential microtasks: await 1; await 2; ensures order.

What is a thenable and how does it affect microtask order?

A thenable is any object with a .then method. When passed to Promise.resolve(), the spec requires that the thenable's .then be called asynchronously, which adds an extra microtask. This can cause the next .then in the chain to execute later than expected, reordering relative to other microtasks.

Does mutation observer callback run as a microtask?

Yes, mutation observer callbacks are microtasks in most browsers. However, their timing relative to Promise microtasks can vary between browsers. In Chrome, they run after Promise microtasks from the same task. In Firefox, they may interleave differently. Avoid relying on their order.