JavaScript is single-threaded, yet servers and browsers stay responsive. The trick is a cooperative scheduler: work is split into tasks, and the event loop runs them one at a time, draining the fastest queue first. One "tick" of that loop is this trace.
The loop takes the next macrotask off the task queue — a script block, an event handler, a setTimeout callback, an I/O callback — and runs it to completion. Nothing interrupts it: no preemption, no timeslice. A task that computes for 500 ms blocks the entire process — this is the famous long-task problem. The queue itself is a FIFO of callbacks held by the embedding environment (the browser's task source, or libuv's loop in Node).
The task finished; before rendering, the loop services the phase queues in Node (timers → pending → poll → check → close) or the browser's task sources (timers, I/O, microtasks are separate). setTimeout(fn, 0) callbacks do not run now — they're macrotasks for the next tick. Node's poll phase is where I/O completion callbacks run; a missing timer deadline is what wakes the poll wait. This phase runs until all queues are empty or the poll phase times out.
Then the loop drains the microtask queue — and here is the ordering trick that defines promise semantics: after every macrotask (and after every microtask), the loop empties the entire microtask queue before touching anything else. Promise.then, queueMicrotask, and await continuations all land here. Because the drain is exhaustive, one microtask that queues another can keep the loop in the drain indefinitely — starve timers, starve I/O, starve rendering. This is the "infinite promise loop freezes the tab" failure mode, and it's not the scheduler's bug; it's the priority you asked for.
In a browser, the tick ends with a potential render step: the browser checks whether a rendering opportunity exists (16.6 ms cadence, or earlier with urgent paint work), and if so runs style, layout, paint, and compositing. The event loop only reaches here if the queues are empty — which is why a busy loop delays paints: requestAnimationFrame callbacks are scheduled here, tied to the render step, not to a timer. Node has no render step; its loop just returns to phase servicing.
node -e "
setTimeout(()=>console.log('timer'),0);
Promise.resolve().then(()=>console.log('microtask'));
console.log('task');" # task → microtask → timer, every timeThe loop returns to step 1: pull the next macrotask. The tick's total time is the sum of everything above, and the loop's one invariant is that only the current task can block. Everything else — timers, I/O, promises, paints — is waiting on the current task's completion. That invariant is the entire contract of JavaScript concurrency: no data races, because nothing runs concurrently — and no fairness, for the same reason.
What the machine actually does is a strict priority auction with three bidders: macrotasks get the turn, microtasks get the leftovers immediately, and rendering gets whatever remains before the next turn. The loop never runs two things at once — its entire performance story is deciding what runs in the gaps.