<< Home

Chapter 16 The Event Loop and Concurrency Model

JavaScript’s concurrency model is one of its most distinctive features. Although JavaScript runs on a single thread, it can still handle multiple tasks efficiently without blocking the main execution flow. This is made possible by the event loop, a mechanism that coordinates the execution of synchronous code, asynchronous callbacks, promises, and scheduled events.

Understanding how the event loop works is essential for mastering asynchronous programming in JavaScript. It explains why some operations appear to happen “in the background,” why certain callbacks run later than others, and how tasks, microtasks, and timers all fit together into a predictable execution model.

This chapter explores how JavaScript manages concurrency behind the scenes. You will learn about the call stack and event queues, the difference between macrotasks and microtasks, how timers and scheduling functions like setTimeout() and setImmediate() behave, and how web workers and worker threads can offload computation onto parallel processes.

💡 The event loop ensures that asynchronous code executes in a consistent order, even though only one statement runs at a time. It continuously checks whether the call stack is empty, then pulls new tasks from the queue for execution.

By the end of this chapter, you will understand how JavaScript’s single-threaded engine can handle multiple operations at once through non-blocking design, and how to use this knowledge to avoid timing issues and race conditions in your own programs.

Call stack, queue, and microtasks

At the heart of JavaScript’s concurrency model lies the call stack and the event loop. The call stack is where the JavaScript engine keeps track of the currently executing functions. Each time a function is called, a new frame is pushed onto the stack. When the function finishes, that frame is popped off, and control returns to the previous one.

function first() {
  second();
  console.log("First done");
}

function second() {
  console.log("Second done");
}

first();

When this code runs, first() is pushed onto the call stack, followed by second(). Once second() finishes, it is removed, and first() resumes. This strict top-down order ensures predictable, synchronous execution.

But what happens when you use asynchronous operations like setTimeout() or a fetch() request? Those functions hand off their work to the browser or runtime environment, allowing JavaScript to continue running other code. When the operation completes, a callback or promise resolution is queued for later execution.

console.log("Start");

setTimeout(() => {
  console.log("Timeout callback");
}, 0);

console.log("End");

Even though the timeout is set to zero, the output will always be:

Start
End
Timeout callback

This happens because the callback is placed in the task queue, waiting for the call stack to clear before being executed. The event loop continually checks whether the stack is empty, then processes queued callbacks in order.

💡 Tasks placed in the queue never interrupt running code. JavaScript completes all current stack operations before handling queued callbacks, preserving consistent, atomic execution.

In addition to the regular task queue, there is also a microtask queue (sometimes called the job queue), which holds promise callbacks and certain other operations. Microtasks run after the current script completes but before the next task from the main queue is processed.

console.log("A");

Promise.resolve().then(() => console.log("B"));

console.log("C");

This code prints:

A
C
B

The promise callback runs as a microtask—after the current stack (which prints A and C) is complete, but before the next macrotask begins. This ordering ensures promise resolution remains tightly coupled to the code that scheduled it.

⚠️ Be cautious when scheduling many microtasks inside loops or chains. Because they run before the next macrotask, excessive microtasks can block timers and UI updates, causing perceived delays or freezes.

By understanding how the call stack, task queue, and microtask queue interact through the event loop, you gain a deeper grasp of how JavaScript schedules work, and why asynchronous behavior in the language is both powerful and predictable.

Timers and scheduling

JavaScript provides several functions for scheduling code to run at a later time, including setTimeout(), setInterval(), and in some environments, setImmediate(). These functions enable delayed or repeated execution without blocking the main thread, forming the basis for animations, polling, and deferred logic.

The most common is setTimeout(), which schedules a callback to run once after a specified delay in milliseconds:

console.log("Start");

setTimeout(() => {
  console.log("Timeout fired");
}, 1000);

console.log("End");

Even if the delay is set to zero, the callback is not executed immediately. Instead, it is placed in the task queue, and will only run once the call stack is clear and all microtasks have finished.

💡 The delay specified in setTimeout() is the minimum wait time before the callback runs. Actual timing depends on system load, current queue length, and browser throttling behavior.

The setInterval() function works similarly but repeats execution at a given interval until cancelled. It is useful for periodic updates such as refreshing data or updating a timer display.

let count = 0;

const timer = setInterval(() => {
  count++;
  console.log("Tick", count);
  if (count === 3) {
    clearInterval(timer);
    console.log("Stopped");
  }
}, 1000);

This example prints “Tick” three times, then stops the interval using clearInterval(). Each callback runs as a separate task, meaning that if one iteration takes longer than the interval, the next call will be delayed until the stack is free again.

⚠️ Timers are not precise for high-frequency or time-critical operations. The event loop, system load, and tab throttling in browsers can all cause noticeable drift. For animation or precise timing, use the requestAnimationFrame() API instead.

Some environments (like Node.js) provide additional scheduling mechanisms such as setImmediate() and process.nextTick(). These differ slightly in timing. process.nextTick() queues a microtask (executed before other tasks), while setImmediate() queues a regular macrotask to run after I/O events complete.

setTimeout(() => console.log("Timeout"));
setImmediate(() => console.log("Immediate"));
process.nextTick(() => console.log("Next tick"));

In Node.js, this will typically print:

Next tick
Timeout
Immediate

Understanding when these scheduling functions execute relative to the call stack, task queue, and microtask queue helps ensure that your asynchronous code behaves predictably and efficiently across both browsers and server environments.

Tasks vs microtasks in practice

Although both tasks and microtasks represent units of work scheduled by the event loop, they run at different times and have distinct priorities. Understanding how they interact is crucial for writing responsive and predictable JavaScript applications.

A task (sometimes called a macrotask) is a general piece of work that enters the event queue, such as the execution of a setTimeout() callback, a DOM event handler, or a message from a web worker. A microtask, on the other hand, is a smaller job that runs immediately after the current task completes but before the next one begins. Promises, queueMicrotask(), and process.nextTick() in Node.js all schedule microtasks.

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");

Even though both setTimeout() and the promise are asynchronous, the output will always be:

A
D
C
B

This happens because microtasks (like the promise callback) run after the current task completes but before any new tasks are taken from the queue. The event loop gives them priority to ensure that small, immediate follow-up actions are completed as soon as possible.

💡 Each time the event loop finishes a task, it first empties the entire microtask queue before starting the next task. This guarantees a consistent ordering and helps avoid subtle timing bugs.

Here's a compact, and intuitive way to make the event loop’s task scheduling visible:

const unsorted = [1, 10, 7, 3, 4, 8, 2, 9, 6, 5];

unsorted.forEach((item) => {
  setTimeout(() => {
    console.log(item);
  }, item * 10);
});

Each number’s delay determines when its callback joins the task queue. Smaller numbers finish first, so the values appear in ascending order on the console, even though the array itself was never sorted. This illustrates how asynchronous scheduling can appear to organize output purely through timing. The result in the numbers 1 through 10 displayed in order.

Consider another example that shows how microtasks can delay other queued operations:

setTimeout(() => console.log("Timeout"), 0);

Promise.resolve().then(function handler() {
  console.log("Microtask");
  Promise.resolve().then(handler);
});

This creates an infinite loop of microtasks that will continuously execute and prevent the timeout from ever running, effectively blocking the event loop. It demonstrates that although microtasks run quickly, too many of them can starve the task queue.

⚠️ Excessive microtasks can delay UI updates, timers, and user interactions. Always ensure that asynchronous chains terminate or yield control back to the event loop when performing long or recursive operations.

In practice, you can think of tasks as major steps in execution (like handling an event or completing an API call), and microtasks as smaller, immediate reactions (like promise resolutions or cleanup steps). This distinction is what allows JavaScript to appear concurrent while still executing in a single-threaded, deterministic order.

Web workers and worker threads

Although JavaScript itself is single-threaded, it can still perform true parallel work using workers. Web workers (in browsers) and worker threads (in Node.js) allow code to run on separate threads, offloading computation-intensive or blocking operations from the main event loop. This keeps user interfaces responsive and prevents long-running tasks from freezing an application.

A web worker runs in its own isolated context, without access to the DOM or main thread variables. Communication occurs through message passing, using postMessage() and the message event. Data is copied or transferred between contexts, not shared directly.

// main.js
const worker = new Worker("worker.js");

worker.onmessage = (event) => {
  console.log("From worker:", event.data);
};

worker.postMessage("Hello from main thread");
// worker.js
onmessage = (event) => {
  console.log("Worker received:", event.data);
  postMessage("Message processed");
};

In this example, both files execute independently. The main thread sends a message to the worker, which performs its task and sends back a response. This message-based model avoids shared state, eliminating many concurrency hazards.

💡 Use web workers for CPU-bound tasks such as image manipulation, data compression, or mathematical computations. They run in parallel, so the main thread can continue responding to input or rendering the page.

Node.js offers a similar concept through the worker_threads module, enabling multiple threads within the same process. Unlike web workers, Node’s worker threads can share memory through SharedArrayBuffer objects, allowing more efficient data exchange when needed.

// main.js
import { Worker } from "node:worker_threads";

const worker = new Worker("./task.js");

worker.on("message", (msg) => console.log("From worker:", msg));
worker.postMessage({ count: 5 });
// task.js
import { parentPort } from "node:worker_threads";

parentPort.on("message", (msg) => {
  let result = 0;
  for (let i = 0; i < msg.count; i++) result += i;
  parentPort.postMessage({ result });
});

Each worker thread has its own event loop and memory space but can exchange data efficiently with the parent thread. This makes worker threads ideal for CPU-heavy operations in server-side JavaScript, where performance and concurrency matter.

⚠️ Because workers run in separate contexts, debugging can be more complex. Avoid excessive worker creation or frequent message passing, which can offset performance gains with communication overhead.

Workers expand JavaScript’s capabilities beyond the single-threaded model, allowing parallel computation while preserving the event loop’s simplicity. They are the preferred tool for heavy background processing in both browser and Node.js environments.

Avoiding race conditions

Even though JavaScript runs on a single thread, asynchronous code can still cause race conditions, which are situations where the timing of events produces unpredictable results. These occur when multiple operations depend on shared data or sequence, but execute in an unexpected order due to asynchronous scheduling.

For example, two asynchronous requests might try to update the same variable:

let userName = "";

fetch("/user/1").then((res) => res.json()).then((data) => {
  userName = data.name;
  console.log("Loaded user:", userName);
});

fetch("/user/2").then((res) => res.json()).then((data) => {
  userName = data.name;
  console.log("Loaded user:", userName);
});

If the second request completes before the first, it will overwrite userName unexpectedly. Both tasks run asynchronously, and their completion order cannot be guaranteed.

⚠️ A race condition doesn’t mean simultaneous execution—it means that the order of completion is non-deterministic, leading to inconsistent state or behavior.

To avoid such issues, ensure that asynchronous logic explicitly defines its dependencies and ordering. One simple approach is to use await or promise chaining to control sequence:

async function loadUsers() {
  const res1 = await fetch("/user/1");
  const user1 = await res1.json();

  const res2 = await fetch("/user/2");
  const user2 = await res2.json();

  console.log("Loaded users:", user1.name, "and", user2.name);
}

This guarantees that /user/1 finishes before /user/2 starts. For independent requests, you can still run them concurrently with Promise.all(), then process results together safely:

async function loadUsersConcurrently() {
  const [res1, res2] = await Promise.all([
    fetch("/user/1"),
    fetch("/user/2")
  ]);

  const [user1, user2] = await Promise.all([
    res1.json(),
    res2.json()
  ]);

  console.log("Loaded both users:", user1.name, "and", user2.name);
}
💡 Use Promise.all() for operations that do not depend on each other, and sequential await for those that do. This ensures predictable execution order without blocking performance unnecessarily.

In more advanced scenarios (such as shared resources, cached data, or stateful applications) you may need to introduce locks, semaphores, or controlled update queues to ensure consistency. Libraries that manage concurrency (for example, mutexify or async-mutex in Node.js) can help coordinate access to shared data safely.

Race conditions can be subtle and difficult to detect, because they depend on timing. By structuring asynchronous logic carefully, keeping operations independent where possible, and clearly defining execution order, you can maintain predictable, race-free behavior even in highly concurrent JavaScript programs.