Event loop

On this page 8

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.

  1. Pick one task from a task queue chosen by the browser, for example a click handler, a timer callback or a message.
  2. Run the task to completion; nothing else can interrupt it on the same thread.
  3. Perform a microtask checkpoint: run every queued microtask, including microtasks added during this step, until the queue is empty.
  4. Update the rendering if this is a rendering opportunity: run resize and scroll steps, requestAnimationFrame callbacks, style, layout and paint.
  5. Repeat, or idle until a new task arrives; idle periods may run requestIdleCallback work.

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 phaseTypical sourcesWhen it runs
Task (macrotask)setTimeout, setInterval, DOM events, postMessage, MessageChannelOne per loop iteration
MicrotaskPromise reactions, await continuations, queueMicrotask, MutationObserverAfter every task, until empty
Animation framerequestAnimationFrame callbacksBefore style, layout and paint
Idle periodrequestIdleCallbackWhen the loop has spare time
Prioritized taskscheduler.postTask with a priorityOrdered by user-blocking, user-visible or background
Where common browser APIs place their callbacks

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.

ParameterTypical valuePractical meaning
Frame budget at 60 HzAbout 16.7 msScript, style, layout and paint must fit
Frame budget at 120 HzAbout 8.3 msLess room per frame on fast displays
Long task threshold50 msTasks above it are reported as long tasks
Good INP target200 ms or lessDelay from input to the next paint
Nested timer clampAbout 4 msMinimum delay after deep nesting
Background timer throttling1 s or longerHidden tabs run timers rarely
Reference values for main-thread work

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.

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.

MechanismThreadRelation to the loop
Callback and timerMain threadQueues a task for later
PromiseMain threadQueues microtasks on settlement
Async/awaitMain threadResumes each await as a microtask
requestAnimationFrameMain threadRuns in the rendering step
Web workerSeparate threadOwn loop, no DOM access
Node.js event loopServer processDifferent phases, built on libuv
How neighboring mechanisms relate to the event loop

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.

SituationRecommended techniqueReason
Long loop over dataChunk work and yield with scheduler.yield or setTimeoutInput can run between chunks
CPU-heavy computationMove it to a web workerMain thread stays free
Visual updatesBatch DOM writes in requestAnimationFrameAligns work with frames
Frequent events such as scrollDebounce, throttle or passive listenersFewer tasks per second
Low-priority analyticsrequestIdleCallback or background priorityRuns only in spare time
Choosing a technique for heavy or frequent work

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 async function 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.