Skip to content

perf: use V8 native queueMicrotask instead of op - #35787

Merged
bartlomieju merged 6 commits into
mainfrom
feat/native-queue-microtask
Jul 6, 2026
Merged

bartlomieju merged 6 commits into
mainfrom
feat/native-queue-microtask

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jul 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #32968

V8 14.7+ ships a native queueMicrotask on the global object behind the
--enable-queue-microtask flag. This PR enables the flag and routes
queueMicrotask through the native builtin instead of the custom
op_queue_microtask op.

  • op_queue_microtask (a nofast op that just called
    isolate.enqueue_microtask) is removed. Our queueMicrotask wrapper
    enqueues through V8's native builtin — no op-boundary crossing per call.
  • Error handling is unchanged: the wrapper still runs the callback under a
    try/catch and routes uncaught exceptions through reportExceptionCallback
    (the error event / uncaughtException), so ordering and async context
    (AsyncLocalStorage) are preserved.
  • V8 skips experimental globals while snapshotting, so the native function is
    only installed at runtime. Internal extensions capture queueMicrotask from
    the primordials at snapshot time, so the wrapper is a stable reference for
    them that lazily grabs the native builtin on first use.

Performance

Release build, Apple M-series, best-case (min) per run vs. the current
canary (op-based, same V8 14.9.207.2-rusty):

Benchmark Baseline (op_queue_microtask) This PR
enqueue + drain ×1000 ~24 µs ~12 µs
per-call enqueue cost ~17 ns ~5.7 ns

Net: ~2× faster on a full enqueue+drain roundtrip, ~3× lower per-call
enqueue cost.

// enqueue + drain roundtrip
Deno.bench("queueMicrotask x1000", async () => {
  await new Promise((resolve) => {
    let n = 1000;
    for (let i = 0; i < 1000; i++) queueMicrotask(() => { if (--n === 0) resolve(); });
  });
});

V8 14.7+ ships a native `queueMicrotask` on the global object behind the
`--enable-queue-microtask` flag. Enable it and have our `queueMicrotask`
wrapper enqueue through the native builtin instead of the custom
`op_queue_microtask` op, removing an op-boundary crossing on every call.

The wrapper's `try/catch` -> `reportExceptionCallback` is preserved, so
uncaught exceptions in microtask callbacks are still routed to Deno's
error handling (not V8's default message listeners) and behavior is
unchanged.

V8 skips experimental globals while snapshotting, so the native function
is only installed at runtime: grab it directly in a from-scratch runtime,
and fall back to a lazy capture (temporarily unshadowing our own global
property) when restoring from a snapshot.

Closes #32968
…ener

Instead of wrapping every queueMicrotask callback in a try/catch closure to
route uncaught exceptions to reportExceptionCallback, register a V8 isolate
message listener and dispatch from there.

V8 reports a throwing queueMicrotask/enqueue_microtask callback via
Runtime::kReportMessageFromMicrotask -> MessageHandler::ReportMessage, which
invokes registered message listeners (and skips the default stderr print).
This fires only for CallableTask microtask exceptions - promise rejections and
TryCatch-caught exceptions don't reach it - so it's a precise replacement for
the JS wrapper.

The listener invokes the realm's JS report-exception callback (dispatching an
error event etc.), falling back to dispatch_exception when none is set. It runs
synchronously, right after the throwing microtask and before the next one, so
ordering and async context are unchanged. The listener is registered on the
runtime isolate only (not the snapshot isolate, whose serialized heap has no
external reference for it).

queueMicrotask now passes the callback straight to V8's native queueMicrotask
with no per-call closure allocation. Uncaught microtask error stacks no longer
include the internal ext:core/01_core.js frame.

Bench (min per run, release, vs the wrapper approach):
  queueMicrotask x1000:  ~11.7us -> ~9.3us  (~20% faster)
  queueMicrotask single: ~77ns   -> ~68ns   (~12% faster)
and ~2.6x over the op_queue_microtask baseline.
dispatch_handled_rejections invoked the "rejectionhandled" handler with a
bare function.call and no TryCatch, unlike its siblings dispatch_rejections
and drain_next_tick_and_macrotasks. A throwing handler left a pending
exception on the isolate, which the newly added microtask message listener
surfaced as an uncaught error (and which could also poison the next
microtask checkpoint).

Wrap the call in a TryCatch so a faulty rejectionhandled handler no longer
leaks. This event only notifies that an earlier rejection was handled, so a
throwing handler must not halt the event loop.
@bartlomieju bartlomieju changed the title perf(ext/core): use V8 native queueMicrotask instead of op perf: use V8 native queueMicrotask instead of op Jul 6, 2026
@bartlomieju
bartlomieju merged commit 242ca14 into main Jul 6, 2026
136 of 137 checks passed
@bartlomieju
bartlomieju deleted the feat/native-queue-microtask branch July 6, 2026 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate V8 14.7 native queueMicrotask (--enable-queue-microtask)

1 participant