JavaScript Event Loop

JavaScript is commonly described as a single-threaded language because its main execution thread runs one piece of JavaScript code at a time. However, modern applications regularly perform asynchronous tasks such as timers, network requests, and user interactions without blocking the main thread.

The event loop coordinates the execution of synchronous code and the processing of asynchronous callbacks. Understanding it helps explain why some callbacks execute before others, even when they were scheduled later.

What Is the Call Stack?

The call stack keeps track of functions that are currently executing. When a function is called, it is added to the stack. When it returns, it is removed. JavaScript executes the function at the top of the stack.

JavaScript
Understanding function execution order.
function first() {
  console.log("First function");
  second();
}

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

first();
console.log("Finished");

The output is First function, Second function, and Finished. The second function runs before first() completes because it is called from inside first().

Synchronous JavaScript

Synchronous statements execute in order. Each statement generally finishes before the next statement begins. A long-running synchronous operation can block the main thread and delay user interface updates.

JavaScript
Synchronous statements execute in sequence.
console.log("Start");
console.log("Processing");
console.log("End");

JavaScript completes each console.log() call before moving to the next one, so the output follows the order of the statements.

How Asynchronous Operations Work

Environment features such as timers and network APIs can manage operations outside the current JavaScript call stack. When an operation is ready, its callback can be scheduled for later execution. The event loop helps process eligible callbacks when the call stack is clear.

JavaScript
A timer callback runs after synchronous code.
console.log("Start");

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

console.log("End");

The output is Start, End, and Timer callback. Even with a delay of zero milliseconds, setTimeout() does not run its callback immediately. It schedules the callback to run when the current synchronous work has finished and the callback is eligible to execute.

Understanding the Task Queue

Callbacks from many asynchronous browser operations, including timers and user events, are scheduled as tasks. These tasks wait until the JavaScript execution context can process them. The event loop coordinates when queued tasks can run.

JavaScript
A task waits for the current code to finish.
console.log("One");

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

console.log("Three");

The synchronous statements print One and Three first. The timer callback then runs as a later task, printing Two.

What Is the Microtask Queue?

Promise callbacks and queueMicrotask() callbacks are scheduled as microtasks. After the current synchronous JavaScript finishes, the runtime processes pending microtasks before moving on to the next task, subject to the host environment's event loop rules.

JavaScript
A Promise callback runs before a timer callback.
console.log("Start");

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

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

console.log("End");

The output is Start, End, Promise, and Timer. The Promise callback is a microtask, so it runs after synchronous code but before the timer task in this example.

Call Stack, Tasks, and Microtasks

A useful way to reason about JavaScript execution is to separate synchronous work from scheduled work. The current call stack runs first. Once it is clear, pending microtasks are processed before the event loop proceeds to another task.

JavaScript
Comparing synchronous code, microtasks, and tasks.
console.log("A");

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

queueMicrotask(() => console.log("C"));

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

console.log("E");

The output is A, E, C, D, B. The two microtasks run in the order they were queued, before the timer callback.

How async and await Use the Event Loop

An async function runs synchronously until it reaches an await expression. When it awaits a Promise, the function pauses and its continuation is scheduled to resume later, typically as a microtask once the awaited Promise is fulfilled.

JavaScript
Observing async function execution.
async function run() {
  console.log("Inside start");
  await Promise.resolve();
  console.log("Inside end");
}

console.log("Before");
run();
console.log("After");

The output is Before, Inside start, After, and Inside end. The function begins immediately, but the code after await continues asynchronously.

Why Blocking Matters

Long-running synchronous code prevents the event loop from processing other JavaScript callbacks on that thread. In a browser, this can make a page unresponsive, delay interactions, and interrupt smooth rendering.

JavaScript
A synchronous loop blocks other callbacks.
setTimeout(() => {
  console.log("Timer callback");
}, 0);

const start = Date.now();
while (Date.now() - start < 2000) {
  // Simulates blocking work
}

console.log("Loop finished");

The timer callback cannot execute while the loop occupies the main thread. It runs only after the synchronous loop finishes and JavaScript can process the scheduled task.

Working Effectively with the Event Loop

Avoid expensive synchronous operations on the main thread when they can be divided into smaller tasks or moved to a Web Worker. Use Promises and async functions for asynchronous workflows, but remember that they do not automatically make CPU-intensive synchronous calculations non-blocking.

When debugging asynchronous code, add logs around callbacks and await expressions to understand when each part executes. Be careful with recursively scheduling large numbers of microtasks, because continually adding microtasks can delay other tasks and rendering.

Summary

The JavaScript event loop coordinates synchronous execution with asynchronous callbacks. The call stack handles currently executing functions, tasks schedule callbacks such as timers, and microtasks handle Promise reactions and queueMicrotask() callbacks.

Synchronous code runs first, then pending microtasks are processed before the next task. Understanding this order makes it easier to debug asynchronous behavior, improve responsiveness, and write more predictable JavaScript applications.