JavaScript Event Loop and Scheduling
JavaScript runs your code on a single thread per realm. Everything asynchronous — timers, network responses, promise callbacks, user input, rendering — is coordinated by the event loop, which decides what runs next. Most of the time you can ignore it. Then a spinner never appears because a loop blocks rendering, a promise callback runs before a timeout you expected to run first, a recursive promise chain freezes the page, or a Node.js server stops answering health checks while it parses a large JSON file. Each of these is explained by a few rules about tasks, microtasks, and rendering.
This page explains those rules in browsers and Node.js, with traces you can run, and shows how to keep long work from blocking everything else.
TL;DR
- Code runs to completion on the call stack; nothing interrupts it.
- After each task (script, timer, event, I/O callback), the engine drains all microtasks (promise reactions,
queueMicrotask). - Browsers may render between tasks — never in the middle of one or while microtasks remain.
- Microtasks can starve the loop: an endless chain blocks timers, input, and rendering.
- Node.js runs callbacks in phases (timers, poll, check);
process.nextTickruns before promise microtasks. - Break up long work with
scheduler.yield(),setTimeout, or chunking, and move CPU-heavy work to workers.
Quick Example
Predict the output, then run it:
The script is one task. When it finishes, the microtask queue drains — both microtasks run before the timer task, even with a delay of 0.
Node.js adds its own queues:
Core Concepts
Tasks and Microtasks
An await splits a function: everything after it runs as a microtask once the awaited promise settles.
The Browser Loop
A simplified iteration: take one task, run it to completion; run all microtasks; if it is time to render (typically each display refresh), run requestAnimationFrame callbacks, recalculate style and layout, and paint. Any code that runs for 200 milliseconds therefore delays input handling and painting by 200 milliseconds — a long task, and a direct cause of poor Interaction to Next Paint.
The Node.js Loop
Node.js uses libuv, which cycles through phases:
Between each callback, Node.js drains the process.nextTick queue first, then the promise microtask queue. Heavy use of nextTick can starve I/O just like recursive promises. See Node.js.
Timers Are Minimums
setTimeout(fn, 10) means "not before 10 ms", not "at 10 ms". Browsers clamp deeply nested timers to at least 4 ms and throttle timers in background tabs heavily, often to once per second or less. A busy loop delays every timer.
Keeping the Loop Responsive
- Yield during long loops so input and rendering can run:
await scheduler.yield()where supported, falling back tosetTimeout. - Chunk work by time rather than item count, checking elapsed time and yielding every few milliseconds.
- Move CPU-heavy work — parsing, image processing, cryptography — to Web Workers or Node.js worker threads.
- Use
requestAnimationFramefor visual updates, so they align with frames instead of fighting them. - Prioritize with
scheduler.postTask()priorities where available.
Best Practices
Think in Tasks When Debugging Order
When callbacks run in a surprising order, label each as task, microtask, or animation frame. The ordering almost always follows from those categories.
Measure Long Tasks
Use the Long Animation Frames API, performance panel recordings, and field INP data to find work that blocks the loop, rather than guessing.
Avoid Synchronous APIs on Hot Paths
In Node.js, fs.readFileSync, crypto.pbkdf2Sync, and large JSON.parse calls block every request. See Node.js Diagnostics and Event Loop Blocking.
Do Not Rely on Timer Precision
Use timestamps for elapsed time and treat timer delays as lower bounds, especially in background tabs.
Common Mistakes
Expecting a Spinner During Synchronous Work
Microtask Starvation
Assuming setTimeout(0) Runs Immediately
FAQ
What is the JavaScript event loop?
The mechanism that runs queued work on JavaScript's single thread: it takes a task, runs it to completion, drains the microtask queue, and, in browsers, renders when needed, then repeats.
What is the difference between a task and a microtask?
Tasks (timers, events, I/O callbacks) run one per loop iteration. Microtasks (promise continuations, queueMicrotask) run immediately after the current task or callback finishes, all of them, before the next task or render.
Why does a promise callback run before setTimeout with zero delay?
Because promise callbacks are microtasks, which run as soon as the current task completes, while setTimeout schedules a new task that must wait for the next loop iteration.
What is the difference between process.nextTick and setImmediate?
process.nextTick callbacks run before any other queued work, right after the current operation. setImmediate callbacks run in the check phase of the next event loop iteration, after I/O polling.
How do I stop long JavaScript tasks from freezing the page?
Break the work into chunks and yield between them with scheduler.yield() or setTimeout, or move CPU-intensive work to a Web Worker.
Related Topics
- JavaScript — The language and its runtimes
- Async JavaScript — Callbacks, promises, and async/await
- Node.js — Server-side JavaScript runtime
- Node.js Diagnostics and Event Loop Blocking — Finding stalls in production
- Web Performance — Responsiveness and rendering
- Concurrency and Parallel Computing — Threads, workers, and parallelism