The Microtask Queue: How V8 Runs Promise Callbacks
V8 keeps one FIFO microtask queue per realm, drains it at a microtask checkpoint after every API call, and runs promise reactions and queueMicrotask callbacks through the same ring buffer.
TL;DR: V8’s microtask queue is not a list of closures - it is a power of two sized ring buffer of
Microtaskobjects, one queue per realm (aNativeContext), owned by the isolate.queueMicrotask, promise reactions,awaitresumption andFinalizationRegistrycleanup all push the same kind of object into the same queue. The queue is drained at a “microtask checkpoint”, which under the defaultkAutopolicy happens automatically when the outermostCallDepthScopein the C++ API unwinds. The cleverest bit is in Torque: aPromiseReactionis not copied when it becomes a job - its map is overwritten so the same object morphs into aPromiseReactionJobTask.
Every JavaScript host has the same problem. You have a stack of work that
must run “soon but not now”: promise callbacks, queueMicrotask jobs,
await resumptions. The naive implementation is a list of closures that
you push to and pop from. V8 does not do that, and the reasons why are
interesting.
One queue per realm, not per isolate
The first surprise is that there is not one microtask queue per isolate.
There is one per realm - a NativeContext holds a pointer to its queue in
a special slot at the end of the context object:
// src/objects/contexts-inl.h:261
MicrotaskQueue* NativeContext::microtask_queue() const {
i::IsolateForPointerCompression isolate = Isolate::Current();
return microtask_queue(isolate);
}
MicrotaskQueue* NativeContext::microtask_queue(
IsolateForPointerCompression isolate) const {
return reinterpret_cast<MicrotaskQueue*>(
ReadCppHeapPointerField<kMicrotaskQueueTag, kMicrotaskQueueTag>(
kMicrotaskQueueOffset, isolate));
}That slot lives outside the tagged heap - it is a C++ heap pointer stored in the external pointer table:
// src/objects/contexts.cc:820
static_assert(NativeContext::kMicrotaskQueueOffset ==
Context::SizeFor(NativeContext::NATIVE_CONTEXT_SLOTS));
static_assert(NativeContext::kSize ==
(Context::SizeFor(NativeContext::NATIVE_CONTEXT_SLOTS) +
NativeContext::kMicrotaskQueueSlotSize));The isolate keeps a list of every queue it has ever seen, plus one designated “default” queue:
// src/execution/isolate.cc:5087
void Isolate::RegisterMicrotaskQueue(MicrotaskQueue* queue) {
CompactMicrotaskQueues();
microtask_queues_.push_back(queue);
}The default queue is what a fresh context gets; an embedder can create
extra queues with v8::MicrotaskQueue::New and attach them to contexts
with Context::SetMicrotaskQueue. Blink does exactly this: one queue per
“agent cluster” or per origin scheme, so that a promise from one realm does
not get interleaved with a promise from an unrelated realm.
This is the “per context / per isolate” subtlety that trips people up.
Isolate::EnqueueMicrotask(Local<Function>) picks the queue from the
function’s creation context, not from the isolate:
// src/api/api.cc:10810
void Isolate::EnqueueMicrotask(Local<Function> v8_function) {
i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(this);
auto function = Utils::OpenDirectHandle(*v8_function);
i::DirectHandle<i::NativeContext> handler_context;
if (!i::JSReceiver::GetContextForMicrotask(function).ToHandle(
&handler_context)) {
handler_context = i_isolate->native_context();
}
MicrotaskQueue* microtask_queue = handler_context->microtask_queue();
if (microtask_queue) microtask_queue->EnqueueMicrotask(this, v8_function);
}The queue is a ring buffer
Here is the whole data structure:
// src/execution/microtask-queue.h:140
// A ring buffer to hold Microtask instances.
// ring_buffer_[(start_ + i) % capacity_] contains |i|th Microtask for each
// |i| in [0, size_).
intptr_t size_ = 0;
intptr_t capacity_ = 0;
intptr_t start_ = 0;
Address* ring_buffer_ = nullptr;A flat Address* array, not a std::vector of handles, not a linked list.
The comment in the enqueue path explains why:
// src/execution/microtask-queue.cc:119
void MicrotaskQueue::EnqueueMicrotask(Tagged<Microtask> microtask) {
if (size_ == capacity_) {
// Keep the capacity of |ring_buffer_| power of 2, so that the JIT
// implementation can calculate the modulo easily.
intptr_t new_capacity = std::max(kMinimumCapacity, capacity_ << 1);
ResizeBuffer(new_capacity);
}
DCHECK_LT(size_, capacity_);
ring_buffer_[(start_ + size_) % capacity_] = microtask.ptr();
++size_;
}Power of two so the builtin can mask instead of divide. The builtin that actually runs the queue reaches into the C++ struct’s fields by raw offset, which is why those offsets are public static constants:
// src/execution/microtask-queue.cc:28
const size_t MicrotaskQueue::kRingBufferOffset =
OFFSET_OF(MicrotaskQueue, ring_buffer_);
const size_t MicrotaskQueue::kCapacityOffset =
OFFSET_OF(MicrotaskQueue, capacity_);
const size_t MicrotaskQueue::kSizeOffset = OFFSET_OF(MicrotaskQueue, size_);
const size_t MicrotaskQueue::kStartOffset = OFFSET_OF(MicrotaskQueue, start_);and the masked index is computed in machine code:
// src/builtins/builtins-microtask-queue-gen.cc:112
TNode<IntPtrT> MicrotaskQueueBuiltinsAssembler::CalculateRingBufferOffset(
TNode<IntPtrT> capacity, TNode<IntPtrT> start, TNode<IntPtrT> index) {
return TimesSystemPointerSize(
WordAnd(IntPtrAdd(start, index), IntPtrSub(capacity, IntPtrConstant(1))));
}The minimum capacity is 8:
// src/execution/microtask-queue.cc:37
const intptr_t MicrotaskQueue::kMinimumCapacity = 8;and the buffer shrinks again when the GC notices the queue is mostly empty
- the queue is visited as a root, and the visitor is allowed to shrink it:
// src/execution/microtask-queue.cc:268
intptr_t new_capacity = capacity_;
while (new_capacity > 2 * size_) {
new_capacity >>= 1;
}
new_capacity = std::max(new_capacity, kMinimumCapacity);
if (new_capacity < capacity_) {
ResizeBuffer(new_capacity);
}What is actually in the queue
The queue holds Microtask objects, and there are exactly six shapes of
them. The whole hierarchy is 30 lines of Torque:
// src/objects/microtask.tq:5
@cppObjectLayoutDefinition
@abstract
extern class Microtask extends Struct {
@if(V8_ENABLE_CONTINUATION_PRESERVED_EMBEDDER_DATA)
continuation_preserved_embedder_data: Object;
}
@cppObjectLayoutDefinition
extern class CallbackTask extends Microtask {
callback: Foreign;
data: Object;
}
@cppObjectLayoutDefinition
extern class CallableTask extends Microtask {
callable: JSReceiver;
context: NativeContext;
}
// Specialized microtask for resuming async generators/functions when the
// awaited/yielded value is a non-thenable. Avoids closure allocation and
// indirect Call dispatch. The |kind| field selects the resume behaviour.
@cppObjectLayoutDefinition
extern class AsyncResumeTask extends Microtask {
generator: JSGeneratorObject;
value: Object;
kind: Smi;
}So a microtask is a typed object, and the runner dispatches on its instance type:
// src/builtins/builtins-microtask-queue-gen.cc:205
int32_t case_values[] = {CALLABLE_TASK_TYPE,
CALLBACK_TASK_TYPE,
PROMISE_FULFILL_REACTION_JOB_TASK_TYPE,
PROMISE_REJECT_REACTION_JOB_TASK_TYPE,
PROMISE_RESOLVE_THENABLE_JOB_TASK_TYPE,
ASYNC_RESUME_TASK_TYPE};That is the whole dispatch: a switch on the instance type, and each arm
does the context switch, the call, and the promise hooks.
queueMicrotask and promise reactions share the queue
The HTML queueMicrotask function is a builtin that allocates a
CallableTask and hands it to the EnqueueMicrotask builtin:
// src/builtins/builtins-microtask-queue-gen.cc:797
TF_BUILTIN(GlobalQueueMicrotask, MicrotaskQueueBuiltinsAssembler) {
auto callback = Parameter<Object>(Descriptor::kCallback);
auto context = Parameter<Context>(Descriptor::kContext);
Label callback_is_not_callable(this, Label::kDeferred);
GotoIf(TaggedIsSmi(callback), &callback_is_not_callable);
GotoIfNot(IsCallable(CAST(callback)), &callback_is_not_callable);
// Use the callback's context as the microtask context, per
// https://webidl.spec.whatwg.org/#js-invoking-callback-functions
// specifically, getting the "associated realm" of the callback.
Label callback_has_no_context(this, Label::kDeferred);
TNode<NativeContext> callback_context =
GetCreationContext(CAST(callback), &callback_has_no_context);
TNode<CallableTask> microtask = TNode<CallableTask>::UncheckedCast(
AllocateInNewSpace(sizeof(CallableTask)));Note that the callback’s creation context, not the caller’s context,
decides which realm’s queue the job lands in. That is the HTML spec’s
“associated realm” rule, and it is why a queueMicrotask callback from an
iframe drains with the iframe’s queue.
The EnqueueMicrotask builtin then finds the queue for the context and
appends to the ring buffer, growing it in C++ if needed:
// src/builtins/builtins-microtask-queue-gen.cc:748
TNode<NativeContext> native_context = LoadNativeContext(context);
TNode<RawPtrT> microtask_queue = GetMicrotaskQueue(native_context);
#endif // https://crbug.com/508092629
// Do not store the microtask if MicrotaskQueue is not available, that may
// happen when the context shutdown.
Label if_shutdown(this, Label::kDeferred);
GotoIf(WordEqual(microtask_queue, IntPtrConstant(0)), &if_shutdown);
TNode<RawPtrT> ring_buffer = GetMicrotaskRingBuffer(microtask_queue);
TNode<IntPtrT> capacity = GetMicrotaskQueueCapacity(microtask_queue);
TNode<IntPtrT> size = GetMicrotaskQueueSize(microtask_queue);
TNode<IntPtrT> start = GetMicrotaskQueueStart(microtask_queue);The fast path is a masked store plus a size bump, entirely in the builtin, with no C++ call at all. Only a full ring buffer falls back to C++:
// src/builtins/builtins-microtask-queue-gen.cc:776
// |microtask_queue| has no space to store |microtask|. Fall back to C++
// implementation to grow the buffer.
BIND(&if_grow);
{
TNode<ExternalReference> isolate_constant =
ExternalConstant(ExternalReference::isolate_address());
TNode<ExternalReference> function =
ExternalConstant(ExternalReference::call_enqueue_microtask_function());
CallCFunction(function, MachineType::AnyTagged(),
std::make_pair(MachineType::Pointer(), isolate_constant),
std::make_pair(MachineType::IntPtr(), microtask_queue),
std::make_pair(MachineType::AnyTagged(), microtask));
Return(UndefinedConstant());
}Promise reactions take exactly the same door. PerformPromiseThenImpl is
where a .then() handler becomes a queued job:
// src/builtins/promise-abstract-operations.tq:468
@export
transitioning macro PerformPromiseThenImpl(
implicit context: Context)(promise: JSPromise,
onFulfilled: Callable|Undefined, onRejected: Callable|Undefined,
resultPromiseOrCapability: JSPromise|PromiseCapability|Undefined): void {
if (promise.Status() == PromiseState::kPending) {
// The {promise} is still in "Pending" state, so we just record a new
// PromiseReaction holding both the onFulfilled and onRejected callbacks.
// Once the {promise} is resolved we decide on the concrete handler to
// push onto the microtask queue.
const promiseReactions =
UnsafeCast<(Zero | PromiseReaction)>(promise.reactions_or_result);
const reaction = NewPromiseReaction(
promiseReactions, resultPromiseOrCapability, onFulfilled, onRejected);
promise.reactions_or_result = reaction;
} else {
...
EnqueueMicrotask(handlerContext, microtask);
}
promise.SetHasHandler();
}Two cases. If the promise is still pending, the reaction is linked onto the
promise itself - a singly linked list hung off reactions_or_result. If
the promise is already settled, a job task is allocated and enqueued right
away.
The trick: a reaction morphs into a job task
When a pending promise settles, its reaction list is drained. And here is
the good bit: V8 does not allocate a new object for the job. It
overwrites the reaction’s map, turning the PromiseReaction into a
PromiseReactionJobTask in place:
// src/builtins/promise-abstract-operations.tq:89
transitioning macro MorphAndEnqueuePromiseReaction(
implicit context: Context)(promiseReaction: PromiseReaction,
argument: JSAny, reactionType: constexpr PromiseReactionType): void {
...
// Morph {current} from a PromiseReaction into a PromiseReactionJobTask
// and schedule that on the microtask queue. We try to minimize the number
// of stores here to avoid write barrier overhead.
static_assert(
kPromiseReactionSize ==
kPromiseReactionJobTaskSizeOfAllPromiseReactionJobTasks);
if constexpr (reactionType == kPromiseReactionFulfill) {
*UnsafeConstCast(&promiseReaction.map) =
PromiseFulfillReactionJobTaskMapConstant();
const promiseReactionJobTask =
UnsafeCast<PromiseFulfillReactionJobTask>(promiseReaction);
promiseReactionJobTask.argument = argument;
promiseReactionJobTask.context = handlerContext;
EnqueueMicrotask(handlerContext, promiseReactionJobTask);The two object layouts are deliberately the same size, and the field offsets are asserted to match:
// src/objects/promise.tq:34
@cppObjectLayoutDefinition
extern class PromiseReaction extends Struct {
@if(V8_ENABLE_CONTINUATION_PRESERVED_EMBEDDER_DATA)
continuation_preserved_embedder_data: Object;
next: PromiseReaction|Zero;
reject_handler: Callable|JSGeneratorObject|Undefined;
fulfill_handler: Callable|JSGeneratorObject|Undefined;
// Either a JSPromise (in case of native promises), a PromiseCapability
// (general case), or undefined (in case of await).
promise_or_capability: JSPromise|PromiseCapability|Undefined;
}So a promise that is resolved with ten .then() handlers costs ten
PromiseReaction allocations and zero job task allocations. The
reactions are recycled into jobs. The next field of the reaction is
reused as the argument field of the job, which is why the static_assert
on the size is there at all.
One more nicety: the list is reversed before it is drained, because reactions are pushed onto the promise in reverse order.
Where the queue is drained
The queue is drained by the RunMicrotasks builtin, which pops from the
front of the ring buffer and runs the task:
// src/builtins/builtins-microtask-queue-gen.cc:839
TF_BUILTIN(RunMicrotasks, MicrotaskQueueBuiltinsAssembler) {
...
BIND(&loop);
TNode<IntPtrT> size = GetMicrotaskQueueSize(microtask_queue);
// Exit if the queue is empty.
GotoIf(WordEqual(size, IntPtrConstant(0)), &done);
...
// Remove |microtask| from |ring_buffer| before running it, since its
// invocation may add another microtask into |ring_buffer|.
SetMicrotaskQueueSize(microtask_queue, new_size);
SetMicrotaskQueueStart(microtask_queue, new_start);
RunSingleMicrotask(current_context, baseline_entered_context_count,
microtask);
IncrementFinishedMicrotaskCount(microtask_queue);
Goto(&loop);The task is removed before it runs, which is why a microtask that enqueues another microtask does not deadlock or lose the new entry: the loop simply picks it up on the next iteration. This is the “the queue drains completely before control returns” semantics.
The C++ entry point is MicrotaskQueue::RunMicrotasks, which jumps into
that builtin through a dedicated JS entry stub:
// src/execution/execution.cc:655
MaybeDirectHandle<Object> Execution::TryRunMicrotasks(
Isolate* isolate, MicrotaskQueue* microtask_queue) {
return InvokeWithTryCatch(
isolate, InvokeParams::SetUpForRunMicrotasks(isolate, microtask_queue));
}with SetUpForRunMicrotasks selecting the JSRunMicrotasksEntry code
object - a separate entry stub from the normal JSEntry, so that the
microtask runner gets its own frame shape.
The checkpoint: where “now” happens
The interesting policy question is when the queue is drained. V8’s answer is the “microtask checkpoint”, and the C++ side has a predicate for it:
// src/execution/microtask-queue.cc:132
bool MicrotaskQueue::ShouldPerformCheckpoint(v8::Isolate* v8_isolate) const {
Isolate* isolate = reinterpret_cast<Isolate*>(v8_isolate);
return !IsRunningMicrotasks() && !GetMicrotasksScopeDepth() &&
!HasMicrotasksSuppressions() &&
isolate->is_javascript_execution_allowed();
}Four conditions, all of which must hold:
- we are not already inside a microtask (no recursion),
- no
MicrotasksScopeis on the stack (the embedder has not asked us to hold off), - microtask execution has not been explicitly suppressed,
- and the isolate is not terminating.
The actual checkpoint has a fast path that avoids setting up a scope at all when there is nothing to do:
// src/execution/microtask-queue.cc:139
void MicrotaskQueue::PerformCheckpointInternal(v8::Isolate* v8_isolate) {
DCHECK(ShouldPerformCheckpoint(v8_isolate));
DCHECK(!microtasks_completed_callbacks_cow_.has_value());
Isolate* isolate = reinterpret_cast<Isolate*>(v8_isolate);
// Fast path: Checkpoints occur frequently when exiting script or microtask
// scopes. If there are no microtasks to drain, no completion callbacks to
// notify, and no kept objects from FinalizationRegistry / WeakRefs to clear,
// we can bail out immediately and avoid artificial MicrotasksScope setup and
// RunMicrotasks overhead.
if (size() == 0 && microtasks_completed_callbacks_.empty() &&
isolate->heap()->weak_refs_keep_during_job() ==
ReadOnlyRoots(isolate).undefined_value()) [[likely]] {
return;
}Note the third condition: the checkpoint is also where WeakRef and
FinalizationRegistry “keep during job” objects get cleared. That is not
an accident - the spec says a WeakRef’s target is only cleared at a
checkpoint, so V8 piggybacks the clearing on the same event.
kAuto, kExplicit and kScoped
There are three policies, and they live in the public header:
// include/v8-microtask.h:28
enum class MicrotasksPolicy { kExplicit, kScoped, kAuto };kExplicit- nothing happens unless the embedder callsRunMicrotasksorPerformMicrotaskCheckpointitself. This is what Node.js uses.kAuto- the queue is drained automatically when the outermost API call returns. This is the default.kScoped- the embedder pushesMicrotasksScopes and the queue drains when the topmost scope withkRunMicrotasksexits.
The kAuto drain point is in FireCallCompletedCallbackInternal, which is
called from the destructor of the API’s CallDepthScope:
// src/execution/isolate.cc:7220
void Isolate::FireCallCompletedCallbackInternal(
MicrotaskQueue* microtask_queue) {
DCHECK(thread_local_top()->CallDepthIsZero());
bool perform_checkpoint =
microtask_queue &&
microtask_queue->microtasks_policy() == v8::MicrotasksPolicy::kAuto &&
!is_execution_terminating();
v8::Isolate* isolate = reinterpret_cast<v8::Isolate*>(this);
if (perform_checkpoint) microtask_queue->PerformCheckpoint(isolate);and the CallDepthScope is what every public API entry point wraps itself
in:
// src/api/api-inl.h:168
~CallDepthScope() {
isolate_->thread_local_top()->DecrementCallDepth(this);
...
if (do_callback) {
i::MicrotaskQueue* microtask_queue =
isolate_->native_context()->microtask_queue(isolate_);
isolate_->FireCallCompletedCallback(microtask_queue);So “after a script runs” and “after an event loop task” are the same
mechanism: the embedder calls into V8, the call depth goes up and comes
back down, and on the way down the queue is drained. A browser event loop
task, a Node tick, a d8 script - all of them are just API calls.
The C++ API surface
Three objects matter to an embedder.
Isolate::PerformMicrotaskCheckpoint - the manual checkpoint:
// src/api/api.cc:10804
void Isolate::PerformMicrotaskCheckpoint() {
DCHECK_NE(MicrotasksPolicy::kScoped, GetMicrotasksPolicy());
i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(this);
i_isolate->default_microtask_queue()->PerformCheckpoint(this);
}MicrotasksScope - the RAII scope, whose destructor is where the
kScoped policy fires:
// src/api/api.cc:11358
MicrotasksScope::~MicrotasksScope() {
if (run_) {
microtask_queue_->DecrementMicrotasksScopeDepth();
if (MicrotasksPolicy::kScoped == microtask_queue_->microtasks_policy() &&
!i_isolate_->has_exception()) {
microtask_queue_->PerformCheckpoint(
reinterpret_cast<Isolate*>(i_isolate_));
DCHECK_IMPLIES(i_isolate_->has_exception(),
i_isolate_->is_execution_terminating());
}
}and MicrotaskQueue::PerformCheckpoint, the one that consults
ShouldPerformCheckpoint first:
// src/execution/microtask-queue.h:56
void PerformCheckpoint(v8::Isolate* isolate) override {
if (!ShouldPerformCheckpoint(isolate)) return;
PerformCheckpointInternal(isolate);
}await is a promise reaction and a resume
The last piece is await, because it is the clearest example of the
queue being used as a coroutine scheduler.
An await in an async function is compiled into a runtime call, a suspend
point, and a resume-mode dispatch:
// src/interpreter/bytecode-generator.cc:6473
void BytecodeGenerator::BuildAwait(int position) {
...
Runtime::FunctionId await_intrinsic_id;
if (IsAsyncGeneratorFunction(function_kind())) {
await_intrinsic_id = Runtime::kInlineAsyncGeneratorAwait;
} else {
await_intrinsic_id = Runtime::kInlineAsyncFunctionAwait;
}
RegisterList args = register_allocator()->NewRegisterList(2);
builder()
->MoveRegister(generator_object(), args[0])
.StoreAccumulatorInRegister(args[1])
.CallRuntime(await_intrinsic_id, args);
}
BuildSuspendPoint(position);BuildSuspendPoint is the SuspendGenerator bytecode - it spills the
register file into the generator object and returns control to the caller.
The await value has to be turned into a reaction first.
For a non-thenable value - which includes every primitive - V8 skips the promise machinery entirely and enqueues a fulfil reaction job whose handler is the generator itself:
// src/builtins/promise-misc.tq:488
transitioning builtin AsyncAwaitNonThenableFastPath(
implicit context: Context)(value: JSAny,
onResolve: JSFunction|JSGeneratorObject,
throwaway: JSPromise|Undefined): Undefined {
// Extract the handler context from the resolve closure/generator.
let handlerContext: Context;
typeswitch (onResolve) {
case (f: JSFunction): {
handlerContext = f.context;
}
case (g: JSGeneratorObject): {
handlerContext = g.context;
}
}
// Create a PromiseFulfillReactionJobTask directly with the non-thenable
// value as the argument. The handler will be called with this value when
// the microtask runs.
const task = NewPromiseFulfillReactionJobTask(
handlerContext, value, onResolve, throwaway);
// Enqueue the microtask.
EnqueueMicrotask(handlerContext, task);
return Undefined;
}And when the job finally runs, the reaction job dispatches on the handler type. If the handler is a generator object, it resumes the generator:
// src/builtins/promise-reaction-job.tq:95
transitioning macro PromiseReactionJob(
context: Context, argument: JSAny,
handler: Callable|JSGeneratorObject|Undefined,
promiseOrCapability: JSPromise|PromiseCapability|Undefined,
reactionType: constexpr PromiseReactionType): JSAny {
typeswitch (handler) {
case (generator: JSGeneratorObject): {
ResumeGenerator(generator, argument, reactionType);
return Undefined;
}ResumeGenerator sets the resume mode and calls the trampoline that
re-enters the suspended frame:
// src/builtins/promise-reaction-job.tq:74
transitioning macro ResumeGenerator(
implicit context: Context)(generator: JSGeneratorObject, argument: JSAny,
reactionType: constexpr PromiseReactionType): void {
if constexpr (reactionType == kPromiseReactionFulfill) {
generator.resume_mode = SmiConstant(kGeneratorResumeModeNext);
} else {
static_assert(reactionType == kPromiseReactionReject);
generator.resume_mode = SmiConstant(kGeneratorResumeModeThrow);
}
if (Is<JSAsyncGeneratorObject>(generator)) {
const asyncGen = %RawDownCast<JSAsyncGeneratorObject>(generator);
asyncGen.is_awaiting = SmiConstant(0);
}
ResumeGeneratorTrampoline(context, argument, generator);So the whole chain is: await x -> enqueue a promise reaction -> the
reaction handler is the generator -> when the microtask runs, the generator
is resumed with the resolved value. The microtask queue is the coroutine
scheduler for async functions.
If you want the other half of that story - what a suspended generator actually looks like in memory - see Generators and async functions: how V8 suspends a function.
What else rides the queue
The queue is not only for promise callbacks. Two more things use it.
WeakRef targets and FinalizationRegistry cleanup. A FinalizationRegistry
cleanup callback is not a microtask, but V8 schedules it through the same
machinery, and the checkpoint is where “keep during job” weak references
get cleared:
// src/heap/finalization-registry-cleanup-task.cc:68
MicrotaskQueue* microtask_queue =
finalization_registry->native_context()->microtask_queue();
if (!microtask_queue) microtask_queue = isolate->default_microtask_queue();
if (microtask_queue &&
microtask_queue->microtasks_policy() == v8::MicrotasksPolicy::kScoped) {
// InvokeFinalizationRegistryCleanupFromTask will call into V8 API methods,
// so we need a valid microtasks scope on the stack to avoid running into
// the CallDepthScope check.
microtasks_scope.emplace(v8_isolate, microtask_queue,
v8::MicrotasksScope::kDoNotRunMicrotasks);
}The embedder’s “microtasks completed” callback. The queue carries a list of these, invoked after the drain finishes:
// src/execution/microtask-queue.cc:334
void MicrotaskQueue::OnCompleted(Isolate* isolate) {
DCHECK_IMPLIES(microtasks_completed_callbacks_.empty(),
!microtasks_completed_callbacks_cow_.has_value());
if (microtasks_completed_callbacks_.empty()) [[likely]] {
return;
}
is_running_completed_callbacks_ = true;
for (auto& callback : microtasks_completed_callbacks_) {
callback.first(reinterpret_cast<v8::Isolate*>(isolate), callback.second);
}and the list is copy-on-write while it is being iterated, so a callback is allowed to add or remove callbacks without invalidating the iteration.
Trade-offs
What it buys. One FIFO with one dispatch point. Because queueMicrotask,
promise reactions and await resumptions all become the same kind of
object, the drain is one loop with one type switch, and the embedder’s
“drain now” is a single call. Because the queue is a flat array of tagged
pointers, enqueueing is a store and a bump, and the GC can visit the whole
queue as a root range without any write barrier on the individual
entries.
What it costs.
- The drain is not preemptive. A microtask that loops forever starves the queue, and there is no priority or fairness mechanism. The ring buffer is strictly FIFO and the runner pops from the front.
- Per-realm queues multiply the state. Every realm needs its own queue,
its own policy, its own completion callbacks. The isolate keeps a list of
all of them, and
Context::SetMicrotaskQueuehas to assert that no microtask is running and no context is entered before it will let you swap one out. - The checkpoint is a side effect of leaving an API call. This is the
cost that surprises people. Any embedder API that returns to JavaScript
without a
MicrotasksScopewill, underkAuto, drain the queue at that point. You cannot run a script and observe the world “between” the script and its microtasks unless you usekExplicit. - The reaction-morphing trick is fragile by construction. It works
because
PromiseReactionandPromiseReactionJobTaskare asserted to have identical size and matching field offsets. Change one layout and you have to change the other, and thestatic_assertis the only thing standing between you and silent memory corruption.
The bottom line
The mental model to keep is: V8 has one microtask queue per realm, it is
a ring buffer of typed Microtask objects, and it is drained at a
checkpoint that, under the default policy, happens when the outermost C++
API call returns.
Everything else follows from that. Promise reactions are microtasks, so
they are FIFO with respect to queueMicrotask calls in the same realm.
await is a microtask, so the resumption of an async function is ordered
relative to promise callbacks. The embedder controls the moment the world
observes “the microtasks have run” by choosing between kExplicit,
kScoped and kAuto.
The most interesting design decision is the reaction morphing. V8 refuses to allocate a second object for a promise job, and instead rewrites the reaction’s map so it becomes one. It is the kind of trick that only makes sense when you have looked at the allocation profile and decided the extra object was not worth it.
The thing people get wrong
The misconception is that “the microtask queue is drained after every
promise callback” or “after every statement”. It is not. The queue is
drained once, at a checkpoint, and the checkpoint happens when control
returns to the embedder. Inside a single Script::Run, a hundred
promise callbacks queued by that script all run back to back, in one
drain, before the embedder sees any of them.
The second misconception is that the queue belongs to the isolate. It
belongs to the realm. A promise created in an iframe and a
queueMicrotask callback from the top-level document land in different
queues in a browser, and they do not interleave.
If you want to observe the world between a script and its microtasks, use
SetMicrotasksPolicy(kExplicit) and call PerformMicrotaskCheckpoint()
yourself. That is exactly what Node.js does, and it is why
process.nextTick and promise callbacks can be observed in a specific
order.