JavaScript Event Loop: A Foundational Overview
Every JavaScript runtime — browsers, Node.js, Deno, Bun, Workers — is built around the same small contract: ECMAScript defines Jobs, agents, and four host hooks; the host plugs in the actual scheduler, queues, timers, and (for browsers) rendering. This article is the foundational overview for that contract. It establishes one mental model that fits every host, then shows precisely where the browser and Node.js diverge, and ends with the patterns and footguns that follow. For host-specific deep dives, see Browser Event Loop, Node.js Event Loop, and libuv Internals.
Thesis
There is no “the JavaScript event loop”. ECMA-262 specifies a deliberately small surface1:
- A Job is an Abstract Closure that “initiates an ECMAScript computation when no other ECMAScript computation is currently in progress” — it runs only when the agent’s execution context stack is empty.
- Four host hooks enqueue Jobs:
HostEnqueueGenericJob,HostEnqueuePromiseJob,HostEnqueueTimeoutJob,HostEnqueueFinalizationRegistryCleanupJob. - An agent is a single thread of evaluation (one stack, one running execution context); an agent cluster is the maximum boundary for shared memory.
- The spec does not define “tasks” vs “microtasks”, “phases”, a render hook, idle callbacks, or any priority order. Those are host policy. §9.5 explicitly notes: “Host environments are not required to treat Jobs uniformly with respect to scheduling. For example, web browsers and Node.js treat Promise-handling Jobs as a higher priority than other work”2.
The portable guarantees you can lean on:
- One Job runs at a time per agent, to completion.
HostEnqueuePromiseJobinvocations execute in invocation order — promise reactions are FIFO per agent.- An async function’s continuation after
awaitis scheduled via a promise reaction Job — never inline, even when the awaited value is a primitive. - The microtask queue is drained to empty between Jobs/tasks, including microtasks that microtasks queue.
Everything else — the rendering integration, libuv phases, process.nextTick, the 4 ms timer clamp, setImmediate, idle scheduling — is host policy and varies between Chromium, Gecko, WebKit, Node.js, Deno, Bun, Cloudflare Workers, and the rest.
Mental model: the universal JS loop
Every host realizes the same shape: a single-threaded engine, a heap, two queues (one for tasks, one for microtasks), and a loop that picks one task, runs it to completion, then drains the microtask queue before doing anything else.
One iteration
A single iteration is the same everywhere: pick one task, push it onto the stack, run synchronous JS to completion, drain microtasks, then — only in the browser — give the rendering pipeline a chance to update.
Three rules are worth memorizing:
- Run-to-completion is per agent, not per task. No other Job in the same agent can preempt the running synchronous JS — that is the spec’s whole concurrency model2. This is why you don’t need locks around purely-JS data structures within a single agent.
- Microtasks drain to empty. If a microtask queues another microtask, it runs before the next task. There is no “drain N microtasks then yield”; you either let the queue run dry or you starve everything that comes after.
- There is no implicit yield. “Async” code is only async at
awaitand Promise-resolution points. Aforloop with noawaitis exactly as blocking as it would be in synchronous JS.
Agents, realms, and clusters (skim)
The spec’s concurrency boundary has three nested concepts3: a Realm is one JavaScript world (its own globalThis and intrinsics — same-origin iframes, vm.createContext(), V8 Contexts); an Agent is one thread of evaluation (window agent, dedicated worker, Node main thread, worker_threads.Worker); an Agent Cluster is the maximum set of agents that can share SharedArrayBuffer memory. A V8 Isolate ≈ an agent; multiple Realms (Contexts) live inside one isolate. Crossing isolates requires postMessage; crossing Realms inside an isolate is a direct call. Cross-realm calls do not yield — they push onto the same execution context stack.
Note
The Agent Record carries [[CanBlock]], which gates Atomics.wait. Browser window agents have [[CanBlock]] = false; Workers and the Node main thread are true. Atomics.waitAsync (ECMAScript 2024) works anywhere because it returns a Promise rather than suspending the thread4.
The four host hooks
ECMA-262 §9.5 defines exactly four hooks, each with its own constraints on top of the basic Job contract.
| Hook | Triggered by | Extra spec constraint |
|---|---|---|
HostEnqueueGenericJob(job, realm) |
Engine bookkeeping; rarely user-triggered | None beyond §9.5 — explicitly not FIFO2 |
HostEnqueuePromiseJob(job, realm) |
Promise.then, await, async function bodies, thenable adoption via Promise.resolve |
Jobs must run in invocation order (FIFO across all promise reactions on the agent)5 |
HostEnqueueTimeoutJob(job, realm, ms) |
Timer infrastructure routed through the spec hook | Run “after at least ms milliseconds”2 |
HostEnqueueFinalizationRegistryCleanupJob(registry) |
A registered target becomes unreachable | Optional — “if possible”; the host may skip it entirely2 |
Two non-obvious things:
- FIFO is only guaranteed for promise jobs.
HostEnqueueGenericJobsays its closures “are intended to be scheduled without additional constraints, such as priority and ordering”2. If you ever see an engine-internal job appear out of order relative to a promise reaction, that is spec-compliant. - Timeouts and timers are host-routed. The HTML spec routes
setTimeoutthrough its own task source (notHostEnqueueTimeoutJob); Node.js routes timers through libuv. The hook exists so an embedder that wants a uniform mechanism can use it.
When a host enqueues a Job that will eventually call into user code, it has to carry the right “settings” across the asynchronous boundary. HostMakeJobCallback returns a JobCallback Record { [[Callback]], [[HostDefined]] }; browsers use [[HostDefined]] to carry the incumbent settings object so a then handler scheduled from frame A but resolved by code from frame B still attributes script origin to frame A. Both HostMakeJobCallback and HostCallJobCallback carry a hard rule: “ECMAScript hosts that are not web browsers must use the default implementation”2. Node, Deno, and Bun are spec-bound to the trivial pass-through.
Promise resolution timing
Promise.then, await, and the body of every async function ultimately schedule promise reactions via two abstract operations6: NewPromiseReactionJob (when a settled promise has a handler attached, or when an attached handler has its promise settled) and NewPromiseResolveThenableJob (when Promise.resolve adopts a foreign thenable). Both go through HostEnqueuePromiseJob.
await is exactly a promise reaction — there is no fast path
The Await abstract operation wraps the awaited value in PromiseResolve (so non-thenables become resolved promises), attaches an internal handler that resumes the async function’s generator state, and suspends the running execution context. The resumption handler runs as a normal promise reaction. There is no synchronous fast path for await Promise.resolve(v) or await 1 — both schedule a microtask:
async function f() { console.log("a"); await 1; console.log("b");}f();console.log("c");Output is a, c, b. The b continuation is scheduled via HostEnqueuePromiseJob and runs after the synchronous console.log("c") completes and the stack unwinds. Anyone relying on await x to be synchronous when x is a primitive is reading non-spec behavior.
then ordering is a language guarantee
Because HostEnqueuePromiseJob is FIFO, two then handlers registered in order on the same agent fire in that order — across nested promises, across realms, in any conformant host. This is one of the few ordering guarantees that does port between Chromium, Gecko, WebKit, Node, Deno, Bun, Cloudflare Workers, and Edge runtimes.
Browser vs Node.js
The two runtimes implement the same hooks but wrap them in very different scheduler shells. The browser model has one task queue (composed of multiple task sources), one microtask checkpoint per task, and a render opportunity that the spec models as a separate task on the rendering task source after the 2024 refactor7. Node’s model is libuv’s phased loop: six phases in fixed order, with the microtask queue (and Node’s own process.nextTick queue) drained between every callback, not only at phase boundaries8.
Browser: WHATWG HTML 8.1.6
The HTML Living Standard overrides every hook on the window/worker event loop7:
HostEnqueuePromiseJobenqueues onto the microtask queue — one queue per event loop, processed at the microtask checkpoint after each task. FIFO follows from the spec.HostEnqueueTimeoutJobqueues onto the timer task source with HTML’s own clamping rules: 1 ms minimum; nested timers (after 5 levels) clamp to 4 ms; background tabs throttle further.- “Update the rendering” is a formal task on the rendering task source (since PR #10007, merged 2024-01-31) — UA-defined frequency, typically aligned to vsync (~16.7 ms at 60 Hz).
requestAnimationFramecallbacks run inside this task, before style/layout/paint. HostMakeJobCallback/HostCallJobCallbackpropagate the incumbent settings object through[[HostDefined]].HostEnqueueFinalizationRegistryCleanupJobruns in a microtask-adjacent slot — when it runs at all.
Node.js: libuv phases + nextTick
Node implements the same hooks against V8’s microtask queue but adds a non-spec channel9:
HostEnqueuePromiseJoblands on V8’s microtask queue, drained by Node after each callback (defaultmicrotaskMode: 'afterTask').HostEnqueueTimeoutJobis bridged to libuv’s timer phase. libuv 1.45 (Node.js 20+) moved timer processing to after the poll phase, sosetImmediate-based yielding can now starve other work under load10.process.nextTickis not a Job. It uses Node’s own queue, drained before the V8 microtask queue. In CommonJS,process.nextTick(f)runs beforePromise.resolve().then(g). In ESM, the entry module evaluation is itself a microtask, soqueueMicrotaskand Promise reactions queued at top level fire beforeprocess.nextTick9.HostMakeJobCallbackuses the spec default — non-browser hosts are required to.process.nextTickis Stability 3 — Legacy as of Node.js 22.7 / 20.18; the docs explicitly recommendqueueMicrotask()instead9.
Portability matrix
| Behavior | Spec requires | Browser | Node | Cross-host portable? |
|---|---|---|---|---|
| One Job at a time per agent | Yes | Yes | Yes | Yes |
Promise reactions in then-call order |
Yes (FIFO) | Yes | Yes | Yes |
| Promise reactions before next timer | No (host policy) | Yes | Yes | Yes (de facto) |
await x schedules a microtask, even for primitives |
Yes | Yes | Yes | Yes |
| Microtask queue drains to empty between tasks | No (host policy) | Yes | Yes (between every callback) | Yes (de facto) |
process.nextTick before microtasks |
No | n/a | Yes (CJS) / No (ESM) | No |
setTimeout(fn, 0) floor |
No | 1 ms (4 ms after 5 nesting levels) | 1 ms | No |
setImmediate exists |
No | No | Yes | No |
| Render opportunity after microtask checkpoint | No | Yes | n/a | n/a |
requestIdleCallback exists |
No | Yes | No | No |
FinalizationRegistry callback ever runs |
No (optional) | Sometimes | Sometimes | No |
Atomics.wait blocks the calling thread |
Yes — only when [[CanBlock]] = true |
Workers only | Always (main + workers) | Partially |
Common patterns and footguns
Microtask starvation
Because the microtask queue drains to empty before the next task can run, a microtask that keeps queuing more microtasks never lets the loop advance. In a browser this freezes rendering and input; in Node it freezes I/O and timers — including the poll phase that would have unblocked the work that resolves the promise.
function spin(): Promise<never> { return Promise.resolve().then(spin);}spin();setTimeout(() => console.log("never runs"), 0);The same shape with queueMicrotask(spin) or with a for await loop over an always-resolved async iterator has the same effect. Recursive then chains are the easiest accidental version: any “polling” loop expressed as await checkAgain() with no underlying I/O will starve the loop.
Warning
If a long-running task must run in chunks, yield with a task (MessageChannel, setTimeout, scheduler.postTask, setImmediate in Node) — not with a microtask. Microtasks do not yield to rendering, input, or I/O.
queueMicrotask vs Promise.resolve().then()
Both schedule onto the same per-agent microtask queue and fire in HostEnqueuePromiseJob order. The differences are intent, overhead, and error semantics11:
queueMicrotask(fn) |
Promise.resolve().then(fn) |
|
|---|---|---|
| Standard intent | Explicit microtask scheduling11 | Side effect of Promise resolution |
| Allocation | None beyond the closure | Allocates a Promise + reaction record |
Throw inside fn |
Reported as a normal unhandled exception (window.onerror / uncaughtException) |
Reported as an unhandled rejection on the returned Promise |
| Argument passing | Closure / bind |
Closure / bind |
| Use it for | ”Run after this turn, before the next task” | Promise pipelines; not as a microtask shim |
Reach for queueMicrotask when you actually want a microtask. Reach for a Promise when you want a Promise.
MessageChannel as a real macrotask trick
setTimeout(fn, 0) does not mean “next tick”. HTML clamps it to 1 ms minimum and to 4 ms once nested past five levels12; background tabs clamp harder. To enqueue a true zero-delay macrotask in the browser, use MessageChannel:
const channel = new MessageChannel();const tasks: Array<() => void> = [];channel.port1.onmessage = () => { const fn = tasks.shift(); fn?.();};function macroTask(fn: () => void) { tasks.push(fn); channel.port2.postMessage(null);}Each postMessage queues a task on the message task source, bypassing the timer clamp. React’s scheduler and many microbenchmark harnesses use this pattern for exactly this reason13.
Tip
Prefer scheduler.postTask({ priority: "user-blocking" | "user-visible" | "background" }) (Prioritized Task Scheduling, available in Chromium since 94 and Firefox since 142; not yet in Safari14) when available — it’s the standard API and exposes priority. Fall back to MessageChannel. Use setTimeout(fn, 0) only when you actually want the timer-source semantics.
process.nextTick is legacy in modern Node
process.nextTick is faster and higher-priority than the microtask queue, but it is not a Job and not portable to any non-Node host. Node 22.7 / 20.18 marked it Stability 3 — Legacy and the docs recommend queueMicrotask() for almost every case9. Two real reasons remain:
- API guarantees. A constructor that needs to give callers time to attach
.on('error')before async work starts must defer withnextTick, not with a Promise (a Promise rejection would already be visible on the next microtask, before user code runs). - CJS vs ESM ordering matters. In CJS,
nextTickcallbacks run beforePromise.thencallbacks queued at the same top level; in ESM they run after, because ESM evaluation is itself a microtask. Anything that depends on this ordering is fragile across module systems9.
If you don’t need either of those, use queueMicrotask and your code keeps the same meaning under Workers, Deno, Bun, and the browser.
FinalizationRegistry is “best effort, sometimes never”
HostEnqueueFinalizationRegistryCleanupJob is the only host hook the spec marks as optional (“if possible”). Cloudflare Workers explicitly disables FinalizationRegistry-driven cleanup — their post-mortem is the canonical “why you should never rely on it”15. Treat any logic whose correctness depends on a finalizer running as broken. Use explicit lifetime instead: using declarations (Stage 4 explicit resource management), close hooks, or your own ref counting.
Atomics.wait blocks; on the main thread, use waitAsync
Calling Atomics.wait on an agent whose [[CanBlock]] = false (browser window agents, by spec) throws TypeError. Use Atomics.waitAsync, which returns { async: true, value: Promise<"ok" | "timed-out"> } and resolves via the normal HostEnqueuePromiseJob path4. The forward-progress guarantee in §9.8 says “every unblocked agent with a dedicated executing thread eventually makes forward progress”16 — that is the only liveness contract you get; anything finer is host policy.
Cross-realm calls don’t yield, even between iframes or vm.Contexts
A function call from one Realm into another inside the same agent pushes onto the same execution context stack. Same-origin iframes share their parent’s window agent; vm.runInContext() runs synchronously on the Node main thread. There is no microtask checkpoint between Realms in the same agent. The only way to actually yield to the host scheduler is to cross an agent boundary: Worker.postMessage, worker_threads, MessageChannel, or a structured-clone-based message.
Where to go next
The host-specific detail this overview deliberately omits lives in the sibling articles:
- Browser Event Loop: Tasks, Microtasks, Rendering, and Idle Time — WHATWG HTML 8.1.6 processing model in depth, render-opportunity selection, long tasks, scheduler.postTask, idle scheduling.
- Node.js Event Loop: Phases, Queues, and Process Exit — libuv phases, nextTick + microtask drain points,
setImmediatevs timers, process exit conditions, the libuv 1.45 timer change. - libuv Internals: Event Loop and Async I/O — handles, requests, the thread pool, epoll/kqueue/IOCP/io_uring backends.
- V8 Engine Architecture — Ignition, TurboFan, the microtask queue implementation.
- Browser Internals — process model, site isolation, scheduler integration with rendering.
Appendix
Prerequisites
- JavaScript Promise mechanics and async/await syntax.
- High-level familiarity with at least one of the browser or Node.js event loops.
Terminology
| Term | Spec section | Definition |
|---|---|---|
| Job | §9.5 | An Abstract Closure with no parameters that initiates an ECMAScript computation when no other ECMAScript computation is currently in progress. |
| Job Abstract Closure | §9.5 | The closure passed to a HostEnqueue*Job hook; must return a normal completion. |
| JobCallback Record | §9.5.1 | { [[Callback]], [[HostDefined]] } — wraps a function so the host can carry context across the async boundary. |
| Realm | §9.3 | A self-contained world: intrinsics, globalThis, current evaluation environment. |
| Agent | §9.6 | A single thread of evaluation: execution context stack, running execution context, Agent Record, executing thread. |
| Agent Cluster | §9.7 | Maximal set of agents that can share memory. Bounds SharedArrayBuffer visibility and Atomics synchronization. |
| Forward Progress | §9.8 | Liveness guarantee: every unblocked agent with a dedicated thread makes progress; one of any thread-sharing set makes progress. |
| Promise Job | §27.2.1.3.2 / §27.2.1.4.1 | A Job created by NewPromiseReactionJob or NewPromiseResolveThenableJob. |
| Microtask | WHATWG HTML / Node docs | Host-level term for a Job enqueued via HostEnqueuePromiseJob (plus MutationObserver records and queueMicrotask callbacks in browsers). Not in ECMA-262. |
| Task / macrotask | WHATWG HTML | Host-level term for a unit of work selected from a task queue. Not in ECMA-262. |
[[CanBlock]] |
§9.6 | Boolean field on the Agent Record gating Atomics.wait. |
Spec section quick-reference
Summary
- ECMA-262 specifies four host hooks (
HostEnqueueGenericJob,HostEnqueuePromiseJob,HostEnqueueTimeoutJob,HostEnqueueFinalizationRegistryCleanupJob) and leaves the actual event loop to the host. - A Job runs only when the agent’s execution context stack is empty. One Job at a time, run-to-completion, no preemption.
- The microtask queue drains to empty between tasks/callbacks — including microtasks that microtasks queue. This is the source of microtask starvation.
HostEnqueuePromiseJobis FIFO across all promise reactions on the agent.await xschedules a microtask even whenxis a primitive.- Browsers implement one task queue (multiple sources), one microtask checkpoint per task, and a render opportunity modeled as a task on the rendering task source.
- Node implements libuv’s six phases with
process.nextTick(legacy in 22+) and the V8 microtask queue drained between every callback. queueMicrotaskis the standard microtask API; prefer it overPromise.resolve().then()for microtask intent, and overprocess.nextTickfor portability.- For real “yield to the scheduler” semantics in the browser, use
scheduler.postTask(orMessageChannelas a fallback), notsetTimeout(fn, 0)— which is clamped to 1–4 ms. FinalizationRegistrycleanup is optional in the spec; never depend on it firing.
Footnotes
-
ECMA-262, 16th edition (June 2025) — Jobs and Host Operations. ↩
-
ECMA-262 §9.5 Jobs and Host Operations to Enqueue Jobs. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
MDN —
Atomics.waitAsync(); standardized in ECMAScript 2024. ↩ ↩2 -
ECMA-262 §27.2.1.3.2 NewPromiseReactionJob, §27.2.1.4.1 NewPromiseResolveThenableJob, and §27.2.5.4.1 PerformPromiseThen. ↩
-
WHATWG HTML — JavaScript specification host hooks and HTML §8.1.6 Event loops; rendering-task refactor in whatwg/html PR #10007 (merged 2024-01-31). ↩ ↩2
-
Node.js — The Node.js Event Loop, Timers, and process.nextTick(). ↩
-
Node.js —
process.nextTick()and “When to use queueMicrotask vs process.nextTick”;process.nextTickis Stability 3 (Legacy) since Node.js 22.7 / 20.18. ↩ ↩2 ↩3 ↩4 ↩5 -
libuv PR #3927 — process timers after the poll phase (libuv 1.45.0, Node.js 20+); see also nodejs/node #57364 and Tasks, microtasks, queues and schedules — Jake Archibald for the conceptual baseline. ↩
-
MDN — Using microtasks in JavaScript with
queueMicrotask()and MDN —Window.queueMicrotask(). ↩ ↩2 -
Alex MacArthur — Picking the Right Tool for Maneuvering JavaScript’s Event Loop (cross-validates the
MessageChannelmacrotask pattern documented by the WHATWG and used in React’s scheduler). ↩ -
MDN —
Scheduler.postTask()(Prioritized Task Scheduling API) and Can I use — Scheduler API: postTask. Chromium since 94 (2021); Firefox since 142 (2025); not yet shipped in Safari. ↩ -
Cloudflare — We shipped FinalizationRegistry in Workers; why you should never use it. ↩