Guide

JavaScript event loop explained

JavaScript runs on a single main thread in the browser (and, by default, one thread per Node.js process). Yet it still handles clicks, network responses, timers, and animations without freezing — because a runtime event loop interleaves your code with callbacks from the host environment. Understanding the call stack, the difference between macrotasks and microtasks, and where async/await actually resumes is how you debug "it logged in the wrong order," fix sluggish wallet popups, and keep Interaction to Next Paint (INP) under Google's 200 ms threshold.

One thread, many sources of work

When you call a function, the engine pushes a stack frame onto the call stack. When the function returns, the frame pops. If the stack grows without bound — infinite recursion — you get RangeError: Maximum call stack size exceeded. That is synchronous execution: one thing at a time, depth-first.

But browsers and Node also receive external events: timer firings, resolved fetch responses, DOM input, IPC messages. Those cannot interrupt a running function mid-instruction (JavaScript has no preemptive threading in user code). Instead, the host queues a callback. When the call stack is empty, the event loop dequeues the next unit of work and runs it to completion. That "run to completion" rule is why a long for loop blocks painting and input until it finishes.

Think of the loop as a repeating cycle:

  1. Execute synchronous code until the call stack is empty.
  2. Drain the microtask queue (Promises, queueMicrotask) until it is empty.
  3. Render pending visual updates in the browser (if it is time to paint).
  4. Take one macrotask from the task queue (timers, I/O, message events) and go to step 1.

Step 3 is browser-specific — Node has no layout engine — but steps 1, 2, and 4 are the mental model every front-end and full-stack developer needs.

Macrotasks vs microtasks

Not all queued callbacks are equal. The engine distinguishes at least two queues:

  • Macrotasks (task queue) — setTimeout, setInterval, I/O completion in Node, message events, user input dispatch in many browsers.
  • MicrotasksPromise reactions (.then, .catch, .finally), async/await continuations, queueMicrotask, and in browsers MutationObserver callbacks.

After each macrotask (or after the initial script), the runtime runs all pending microtasks before taking the next macrotask or painting. That is why this classic snippet prints 1, 2, 3, 4 and not 1, 3, 2, 4:

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

Order of execution: synchronous 1 and 4; then the microtask 3; then the timer macrotask 2. Interviewers love this puzzle because it exposes whether you understand scheduling, not just syntax.

Starvation warning: an endless chain of microtasks (each Promise scheduling another) can delay timers and input handling. Batch work or yield to macrotasks with setTimeout(fn, 0) or requestIdleCallback when processing large arrays on the main thread.

Promises and async/await

async/await is syntactic sugar over Promises. An await expression pauses the async function and registers the remainder as microtasks on the Promise chain. It does not spawn a new OS thread.

async function loadBalance(rpcUrl) {
  const response = await fetch(rpcUrl); // yields; microtask resumes later
  const json = await response.json();
  return json.result.value;
}

While await fetch waits on the network, the main thread is free to run other tasks — paint, handle the next click, process WebSocket frames. When the response arrives, the continuation is enqueued as a microtask. That is why wallet dApps can show a spinner during RPC calls without blocking the entire tab, as long as you do not synchronously parse megabytes of JSON on return.

Promise.all and Promise.race schedule their aggregators as microtasks too. For parallel RPC reads (balance + token accounts), prefer Promise.all over awaiting inside a loop — you still get one thread, but I/O overlaps. See our Solana RPC endpoints guide for why redundant polling across fallbacks can flood the microtask queue during outages.

requestAnimationFrame and rendering

Animations and canvas games should not drive frames with setInterval. requestAnimationFrame (rAF) schedules a callback before the next compositor paint, typically aligned to display refresh (60 Hz or 120 Hz). rAF callbacks are macrotasks from the perspective of ordering relative to microtasks — after microtasks drain for the current turn.

A common pattern in game loops (covered in our game loop and frame timing guide): read input synchronously, update simulation, then rAF for render. Heavy physics should not run inside the rAF callback if it blows the 16.7 ms frame budget; chunk simulation across microtasks or Web Workers instead.

Browsers may skip rAF when the tab is backgrounded. Do not rely on rAF for wall-clock timing of payments or fairness seeds — use performance.now() or server time for verifiable logic.

Node.js: same idea, different I/O

Node's event loop is more granular (timers, pending callbacks, poll, check, close callbacks phases), but the lesson is identical: JavaScript runs on one thread; libuv handles sockets and files on a thread pool; completion callbacks return as tasks on the JS thread. A CPU-bound JSON transform in an Express handler blocks every concurrent request in that process — offload to Worker threads or a queue service (see message queues explained).

setImmediate vs process.nextTick in Node: nextTick runs before the next event loop phase (similar priority to microtasks); setImmediate runs in the check phase after I/O poll. Mixing them carelessly can reorder logs in tests; prefer standard Promises for portable code shared between browser and Node.

Web Workers and SharedArrayBuffer

When work cannot be chunked (cryptography, large spreadsheet parsing, physics for hundreds of entities), move it off the main thread with Web Workers. Workers have their own event loop and message queue; communication is postMessage (structured clone) or SharedArrayBuffer with explicit atomics for tight loops.

Workers cannot touch the DOM. Pattern: main thread owns UI; worker returns results via messages that schedule microtasks on the main thread. For near-native speed in the browser, compile hot paths to WebAssembly and call from either thread depending on your bundler setup.

INP, long tasks, and user-perceived jank

Google's Interaction to Next Paint (INP) measures latency from user input (click, tap, key) until the browser paints the next frame showing feedback. Anything that keeps the main thread busy for more than ~50 ms risks a "long task" in DevTools Performance panel and poor INP in the field.

  • Defer non-critical JStype="module" defers by default; split bundles so route-level code loads on navigation (SSR vs CSR trade-offs).
  • Break up synchronous loops — process N items per frame or use Workers.
  • Avoid layout thrashing — batch DOM reads/writes; see CSS layout guides for CLS-safe patterns.
  • Yield after input handlers — heavy work can start in requestAnimationFrame or scheduler.postTask (priority hints) so the browser paints hover/active states first.

Real-time channels (WebSockets and SSE) deliver messages as macrotasks. Burst traffic can flood the loop — throttle UI updates and coalesce state diffs instead of re-rendering on every packet.

Debugging scheduling bugs

Symptoms and likely causes:

  • "Sometimes" stale UI — state updated in a microtask but read synchronously in the same click handler before the microtask runs; use await or pass fresh values explicitly.
  • Tests flaky on CI — missing await on async setup; timer order differs from production; use await Promise.resolve() or fake timers with documented macrotask behavior.
  • Double submission on buttons — no disable until microtask completes; guard with an in-flight flag set synchronously at the start of the handler.
  • Memory growth in SPAs — closures held by timers or unresolved Promises; clear intervals on route teardown.

Chrome DevTools: Performance records main-thread flame charts; enable "Screenshots" to correlate long tasks with frozen frames. The Sources panel can pause on Promise resolutions when debugging async stacks.

Key takeaways

  • JavaScript executes synchronously on a call stack until empty, then processes queued work.
  • Microtasks (Promises, await) run before the next macrotask (timers, I/O, input).
  • async/await does not parallelize CPU — it frees the thread during I/O waits.
  • Use rAF for visuals, Workers/WASM for heavy CPU, and chunk work to protect INP.
  • Node shares the model; blocking the event loop blocks the entire server process.

Related reading