The event loop is the scheduling model that lets single-threaded JavaScript in the browser handle timers, user input, network responses and rendering without blocking. In browser JavaScript, it repeatedly takes one task from a queue, runs it to completion, empties the microtask queue, and then gives the browser a chance to update the rendering of the page.
The model is defined by the HTML Living Standard, not by the ECMAScript language specification, which is why its exact behavior belongs to the web platform. Understanding it explains why a long loop freezes a page, why a resolved Promise runs before a zero-delay timer, and why responsiveness depends on short tasks.
How the event loop processes work
The event loop works as an endless cycle of task, microtasks and rendering. Each window, iframe group or worker has its own loop, and each loop owns several task queues plus one microtask queue. The JavaScript engine itself only executes code on a call stack; the loop decides which code reaches that stack next.
- Pick one task from a task queue chosen by the browser, for example a click handler, a timer callback or a message.
- Run the task to completion; nothing else can interrupt it on the same thread.
- Perform a microtask checkpoint: run every queued microtask, including microtasks added during this step, until the queue is empty.
- Update the rendering if this is a rendering opportunity: run resize and scroll steps,
requestAnimationFramecallbacks, style, layout and paint. - Repeat, or idle until a new task arrives; idle periods may run
requestIdleCallbackwork.
The key rule is run-to-completion. Once a function starts, it finishes before any event handler, timer or network callback can run. This removes data races inside one thread, but it also means that any long synchronous task blocks input and painting for its whole duration.
Task queues, microtasks and rendering steps
Work enters the loop through different queues with different priorities. The specification lets browsers choose between task sources, and modern engines give user input a higher priority than timers or background messages. Microtasks are not prioritized among themselves; they run in strict first-in, first-out order.
| Queue or phase | Typical sources | When it runs |
|---|---|---|
| Task (macrotask) | setTimeout, setInterval, DOM events, postMessage, MessageChannel | One per loop iteration |
| Microtask | Promise reactions, await continuations, queueMicrotask, MutationObserver | After every task, until empty |
| Animation frame | requestAnimationFrame callbacks | Before style, layout and paint |
| Idle period | requestIdleCallback | When the loop has spare time |
| Prioritized task | scheduler.postTask with a priority | Ordered by user-blocking, user-visible or background |
Because the microtask queue is drained completely, a microtask that keeps scheduling microtasks starves the loop: no task, no input handler and no repaint can run. Timers behave differently. A setTimeout delay is a minimum, not a guarantee, and browsers clamp nested timers to at least about 4 ms after five levels of nesting, while inactive background tabs throttle timers to once per second or less.
Timing parameters that shape responsiveness
The event loop has no fixed speed; its effective budget comes from the display refresh rate and from how long each task occupies the main thread. Performance tooling translates those limits into concrete thresholds that developers can measure.
| Parameter | Typical value | Practical meaning |
|---|---|---|
| Frame budget at 60 Hz | About 16.7 ms | Script, style, layout and paint must fit |
| Frame budget at 120 Hz | About 8.3 ms | Less room per frame on fast displays |
| Long task threshold | 50 ms | Tasks above it are reported as long tasks |
| Good INP target | 200 ms or less | Delay from input to the next paint |
| Nested timer clamp | About 4 ms | Minimum delay after deep nesting |
| Background timer throttling | 1 s or longer | Hidden tabs run timers rarely |
A task longer than 50 ms delays any input that arrives during it, which directly harms Interaction to Next Paint. The cure is not faster hardware but shorter tasks: splitting work into chunks and yielding to the loop between them so that input and rendering can run.
Event loop compared with related concurrency tools
The event loop is the foundation that other asynchronous features build on. Promises and async/await do not create threads; they only decide when a continuation is queued as a microtask. Web workers and service workers, by contrast, run on separate threads with their own event loops and communicate through messages.
| Mechanism | Thread | Relation to the loop |
|---|---|---|
| Callback and timer | Main thread | Queues a task for later |
| Promise | Main thread | Queues microtasks on settlement |
| Async/await | Main thread | Resumes each await as a microtask |
| requestAnimationFrame | Main thread | Runs in the rendering step |
| Web worker | Separate thread | Own loop, no DOM access |
| Node.js event loop | Server process | Different phases, built on libuv |
The Node.js loop shares the run-to-completion principle but has its own phases for timers, I/O polling and setImmediate, so ordering tricks learned in one environment do not always transfer to the other.
Keeping the event loop responsive in practice
Responsive pages treat main-thread time as a scarce shared resource. The practical choice depends on what kind of work blocks the loop and whether it needs the DOM.
| Situation | Recommended technique | Reason |
|---|---|---|
| Long loop over data | Chunk work and yield with scheduler.yield or setTimeout | Input can run between chunks |
| CPU-heavy computation | Move it to a web worker | Main thread stays free |
| Visual updates | Batch DOM writes in requestAnimationFrame | Aligns work with frames |
| Frequent events such as scroll | Debounce, throttle or passive listeners | Fewer tasks per second |
| Low-priority analytics | requestIdleCallback or background priority | Runs only in spare time |
Yielding with a resolved Promise does not help, because the continuation runs as a microtask before rendering. Only a new task or an explicit yield API returns control to input handling and painting.
Common misconceptions and failure modes
- Async code is not parallel code. An
asyncfunction still runs on the main thread between its awaits. - A zero-delay timer is not immediate. It waits for the current task, all microtasks and possibly a frame.
- Infinite microtask chains freeze the page more completely than an infinite timer chain.
- Layout thrashing happens when one task alternates DOM reads and writes, forcing repeated synchronous layout.
- Ordering between task sources is not guaranteed, so code must not depend on a click handler running before a timer.
Most event loop bugs appear as jank, delayed clicks or frozen spinners, and the performance panel of browser developer tools shows them as long tasks with the responsible call stacks.
The idea in brief
Picture a single cashier serving a queue. The cashier helps one customer at a time and always finishes before calling the next. Small follow-up requests from the current customer are handled before anyone new is called, and every so often the cashier tidies the counter so others can see what changed.
The browser works the same way. One worker handles every job on a page: clicks, timers, data from the network and redrawing the screen. If one job takes too long, everyone waits, which is why a page can feel frozen. Keeping each job short keeps the page responsive.
A practical scenario
A team builds an online spreadsheet that recalculates 20,000 formula cells when a user edits a value. In the first version, the recalculation runs as a single function that takes about 400 ms on a mid-range laptop. During that time the cursor stops blinking, typed characters appear late and the page reports a poor Interaction to Next Paint above 500 ms.
The developers split the work into chunks of about 5 ms, yield to the event loop between chunks and update visible cells first inside requestAnimationFrame. Formulas that do not affect the visible area move to a web worker. Total calculation time stays similar, but typing becomes immediate and the measured interaction latency falls below 100 ms.