Frontend Development 18 min read

Understanding the JavaScript Event Loop in Browsers and Node.js

This article explains the fundamentals of JavaScript's event loop, covering browser asynchronous execution, macro‑ and micro‑tasks, timer inaccuracies, view rendering, and the differences in Node.js implementation, while providing code examples to illustrate execution order and practical implications for developers.

ByteFE
ByteFE
ByteFE
Understanding the JavaScript Event Loop in Browsers and Node.js

For front‑end developers, mastering the JavaScript Event Loop is essential because it underpins many high‑frequency interview topics and everyday debugging tasks.

Browser JS Asynchronous Execution Principle

JavaScript runs on a single thread, but browsers are multi‑threaded; when an async task is needed, the browser spawns a separate thread (e.g., HTTP request thread, timer thread) to handle it while the main JS engine thread continues executing synchronous code. The callback is later queued for the JS thread.

In Chrome, each tab runs in its own rendering process, which contains the JS engine thread, HTTP request thread, and timer thread, providing the foundation for asynchronous operations.

Event‑Driven Overview

Both browsers and Node.js use an event‑driven model: events trigger tasks, and the event loop manages their execution. For example, clicking a button can trigger a render event (event‑driven) or be handled by a polling loop (state‑driven).

Browser Event Loop

Execution Stack and Task Queues

Synchronous code is placed on the execution stack and run in order. When an async operation finishes, its callback is put into a task queue; after the stack is empty, the event loop pulls callbacks from the queue back onto the stack.

Macro‑tasks and Micro‑tasks

Task queues are divided into macro‑task and micro‑task queues. After the stack empties, the event loop first processes all micro‑tasks, then proceeds to a macro‑task. Micro‑tasks (e.g., Promise.then , MutationObserver , process.nextTick ) run before the next macro‑task, while macro‑tasks (e.g., setTimeout , setInterval , setImmediate ) wait for the next tick.

console.log('同步代码1');
setTimeout(() => {
    console.log('setTimeout');
}, 0);
new Promise((resolve) => {
  console.log('同步代码2');
  resolve();
}).then(() => {
    console.log('promise.then');
});
console.log('同步代码3');
// Output: 同步代码1, 同步代码2, 同步代码3, promise.then, setTimeout

Micro‑tasks are executed in the current loop iteration, while macro‑tasks wait for the next iteration, which can cause timer inaccuracies when the main thread is busy.

Timer Inaccuracy

If the main thread spends a long time on synchronous work, timers set with setTimeout(…, 0) may fire later than expected, leading to skipped seconds in a clock UI.

View Rendering

After each event‑loop turn, the browser may batch DOM updates and perform a repaint. The repaint occurs after micro‑tasks are drained, so not every DOM change triggers an immediate visual update.

Node.js Event Loop

Node.js relies on the libuv library to implement its event loop. The loop consists of several phases, each with its own task queue:

timers : executes setTimeout and setInterval callbacks.

pending callbacks : handles I/O callbacks such as TCP errors.

poll : waits for new I/O events; if the queue is empty, it may proceed to the check phase.

check : runs setImmediate callbacks.

close callbacks : runs close event handlers.

Each phase processes its queue, then drains the micro‑task queue before moving to the next phase.

const fs = require('fs');
fs.readFile(__filename, (data) => {
    console.log('readFile');
    Promise.resolve().then(() => console.error('promise1'));
    Promise.resolve().then(() => console.error('promise2'));
});
setTimeout(() => {
    console.log('timeout');
    Promise.resolve().then(() => console.error('promise3'));
    Promise.resolve().then(() => console.error('promise4'));
}, 0);
// Output (Node 10): timeout promise3 promise4 readFile promise1 promise2

In newer Node versions (>=11), the order aligns with browsers: after each timer callback, micro‑tasks are processed.

nextTick, setImmediate and setTimeout

process.nextTick runs immediately after the current synchronous code, before any other async callbacks, while setImmediate runs in the check phase. Their relative order can affect output:

setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.error('promise'));
process.nextTick(() => console.error('nextTick'));
// Output: nextTick, promise, timeout

When both setTimeout and setImmediate are used, the timer callback executes in the timers phase, and the immediate callback runs later in the check phase, unless the timer hasn't expired yet.

Summary

The article provides a comprehensive comparison of the event loop in browsers and Node.js, explaining how macro‑tasks, micro‑tasks, timers, and rendering interact, and demonstrates how understanding these mechanisms helps developers write more predictable asynchronous code and optimize performance.

JavaScriptNode.jsbrowserasyncEvent LoopMicrotasksMacrotasks
ByteFE
Written by

ByteFE

Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.