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:
- Execute synchronous code until the call stack is empty.
- Drain the microtask queue (Promises,
queueMicrotask) until it is empty. - Render pending visual updates in the browser (if it is time to paint).
- 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,messageevents, user input dispatch in many browsers. - Microtasks —
Promisereactions (.then,.catch,.finally),async/awaitcontinuations,queueMicrotask, and in browsersMutationObservercallbacks.
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 JS —
type="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
requestAnimationFrameorscheduler.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
awaitor pass fresh values explicitly. - Tests flaky on CI — missing
awaiton async setup; timer order differs from production; useawait 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/awaitdoes 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
- Core Web Vitals explained — INP thresholds and field measurement
- TypeScript fundamentals explained — typing async APIs and Promise return types
- WebSockets and server-sent events — real-time messages as event-loop tasks
- SSR vs CSR vs SSG vs ISR — when JavaScript loads and hydrates on the main thread