This is the rule behind a famously confusing piece of JavaScript behavior:
Promise.resolve().then(cb) always runs before the next setTimeout(cb2, 0),
no matter what the source order looks like. The reason: after EVERY
macrotask (the initial script, a timer callback, an I/O callback), ALL
currently-queued microtasks (Promise callbacks) run to completion -- including
any NEW microtasks they themselves queue -- before the next macrotask is even
considered.
Tasks are functions fn(loop, log) -- they append a label to log and may
call loop.queue_microtask(...) / loop.queue_macrotask(...) to enqueue more
work, mirroring how a real callback can itself schedule more callbacks.
Implement EventLoop.run(): run macrotasks one at a time (FIFO order), and
after EACH macrotask, fully drain the microtask queue -- including microtasks
newly queued while draining -- before running the next macrotask. Return
log.
Example
loop.queue_macrotask(macro1) # queues 2 microtasks when it runs
loop.queue_macrotask(macro2)
loop.run()
# -> ["macro1", "micro1", "micro2", "macro2"]
# both microtasks drain BEFORE macro2, even though macro2 was already
# queued when macro1 started running
Constraints
- Microtask draining must be a loop that keeps going as long as ANY microtasks remain -- including ones queued by a microtask that just ran, not just the ones present when draining started.
Real-world grounding
- Node's task queue implementation (macrotasks alongside
process.nextTick/ Promise microtasks):lib/internal/process/task_queues.js
This exact ordering rule (drain microtasks fully between every macrotask) is part of the HTML/ECMAScript event loop specification, which is why it's identical across every JS runtime -- Node, browsers, Deno all implement the same rule.