Skip to content
A single USB-C cable arranged in a continuous, flowing loop on a warm, uncluttered wooden desk in a modern workspace

javascript event loop mechanics: how async code executes

THE BOTTOM LINE

JavaScript runs one piece of code at a time, but its host environment lets slow work finish elsewhere and queues callbacks for later execution.

  • Synchronous code runs on the call stack before queued callbacks.
  • Promise callbacks run as microtasks after the current task, before the next timer or user event.
  • setTimeout() sets a minimum delay, not an execution deadline.
  • Long synchronous work blocks browser input, rendering, timers, and other callbacks.

The exact order changes between browsers and Node.js, especially around rendering, timers, setImmediate(), and process.nextTick().

What Happens Before JavaScript Code Executes?

The JavaScript engine and its host environment

Before JavaScript runs, a host loads the source, parses it, and passes executable code to an engine such as V8, SpiderMonkey, or JavaScriptCore. The engine supplies the language runtime, while the browser or Node.js supplies host features such as timers, network requests, files, and event handling.

The engine does not implement setTimeout() or the Document Object Model (DOM) itself. A browser exposes those features through Web APIs, and Node.js exposes its own APIs through libraries and the operating system.

Execution contexts and the call stack

An execution context stores the information needed to run a script or function, including its variables and scope. JavaScript creates a global context first, then adds a function context whenever a function is called.

The call stack tracks those contexts in last-in, first-out order. A function call is pushed onto the stack, its statements run, and the context is removed when the function returns. An exception that is not handled unwinds the stack and can terminate the current task.

What Are the Core Components of the Event Loop?

  • Call stack: runs the currently executing JavaScript.
  • Web APIs or Node.js APIs: handle host operations such as timers, network activity, DOM events, and file access.
  • Task queues: hold callbacks that are ready to run, including timer and event callbacks.
  • Microtask queue: holds Promise reactions, queueMicrotask() callbacks, and browser observer callbacks.
  • Event loop: coordinates when queued work can return to JavaScript.

Call stack

The call stack can run only one JavaScript frame at a time on the main thread. A callback cannot interrupt a function that is already running, even if the callback became ready while that function was executing.

This run-to-completion rule makes ordinary JavaScript state changes predictable. It also means a large loop or expensive synchronous function prevents every other JavaScript callback from starting.

Web APIs or Node.js APIs

Host APIs perform operations that do not need to occupy the JavaScript stack continuously. For example, a browser can ask its networking layer to fetch a resource, while Node.js can ask the operating system to handle a socket or file operation.

When the operation reaches a callback-ready state, the host schedules that callback. The event loop does not move it onto the stack until the current JavaScript task has finished and the relevant queue can be processed.

Task queues

A task, often called a macrotask, is a unit such as a timer callback, user interaction, script execution, or some network event. Task ordering is not one universal first-in, first-out queue across every browser API, so you should avoid assuming that unrelated sources have a fixed order.

Microtasks use a separate queue with higher priority at defined checkpoints. Promise reactions are therefore usually observed before a ready timer, but they still cannot interrupt the task that created them.

Event loop

The event loop waits until JavaScript can run, selects work according to the host’s scheduling rules, and begins another task. After a task finishes, the runtime reaches a microtask checkpoint and drains the microtask queue before proceeding.

The browser also considers a rendering opportunity between tasks. Node.js instead advances through its event-loop phases, with its own rules for timers, input and output, polling, and immediate callbacks.

How Does the Event Loop Run Code?

Run-to-completion

JavaScript executes each task from start to finish before another task begins. If a click handler performs 200 ms of synchronous work, a timer that became ready during that period must wait until the handler returns.

The ECMAScript specification defines execution semantics for the language, but it leaves many scheduling details to the host. That is why browser event-loop behavior and Node.js behavior share principles without being identical.

Returning control to the event loop

Control returns to the event loop when the current script or callback returns, throws an uncaught exception, or otherwise finishes. Only then can queued microtasks run and a later task reach the call stack.

An async function does not make its entire body background work. Code before the first await runs synchronously, and the continuation after await is scheduled through Promise machinery.

Why JavaScript remains non-blocking

JavaScript remains responsive when the host performs waiting work outside the call stack and your callbacks are short. It does not mean the JavaScript thread runs multiple callbacks simultaneously.

This distinction matters when you assess a frontend architecture or backend service. Network waiting can be cheap for the JavaScript thread, while parsing a very large response or transforming millions of records can block it.

What Are Tasks, Microtasks, and Rendering?

Macrotasks and task queues

Timers, DOM events, and many host callbacks enter task scheduling. A timer with a 0 ms delay still waits for the current task and any required microtasks to finish.

The term macrotask is widely used in tutorials, but browser specifications generally use task. Treat it as a useful classification rather than a promise that every host has one queue with identical priorities.

Microtasks and the Promise queue

Promise reactions from .then(), .catch(), and .finally() are queued as microtasks. queueMicrotask() adds work to the same general scheduling category.

The runtime drains microtasks until the queue is empty. A microtask that creates another microtask extends the same drain, which can delay timers and rendering if code keeps adding more work.

Rendering opportunities in browsers

A browser can update the screen between tasks, after the relevant microtask checkpoint, subject to frame timing and rendering conditions. The browser is not required to repaint after every callback.

For animation, requestAnimationFrame() schedules work near a browser’s next rendering opportunity. A long task, or a large microtask drain immediately before rendering, can still produce a missed frame.

What Is the Order of Execution?

  • Synchronous code: the current script or callback runs on the call stack.
  • Microtasks: Promise reactions and other microtasks are drained after the current task.
  • Timers and other tasks: the host selects a ready task according to its scheduling rules.
  • Rendering and the next event-loop turn: a browser may render before starting another task.

Synchronous code

Top-level statements execute first. Function calls made by those statements finish before the next top-level statement runs, unless the function itself schedules later work.

Microtasks

Once the current task completes, the runtime processes queued microtasks before selecting a later timer or event task. This is why a resolved Promise often logs before setTimeout(callback, 0).

Timers and other tasks

A timer becomes eligible after its delay has elapsed, but eligibility does not reserve the next execution slot. Earlier tasks, garbage collection, operating-system scheduling, and rendering work can all affect when its callback runs.

Rendering and the next event-loop turn

After microtasks drain, a browser may perform layout, paint, and compositing before the next task. This is an opportunity, not a guaranteed fixed sequence for every callback source.

How Do You Predict Output with Common Async Code?

Predicting output with setTimeout, Promise, and synchronous code

For this example, the output is Start, End, Promise, then Timeout:

console.log('Start');

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

Promise.resolve().then(() => console.log('Promise'));

console.log('End');

Start and End run synchronously. The Promise reaction becomes a microtask, while the timer becomes a later task, so the microtask runs first.

Nested microtasks and task scheduling

A microtask can enqueue another microtask, and the new callback is normally processed before the next task:

Promise.resolve().then(() => {
  console.log('A');
  queueMicrotask(() => console.log('B'));
});

setTimeout(() => console.log('C'), 0);

The output is A, B, then C. Reading the JavaScript call stack microtasks and the host API that schedules each callback is more reliable than reasoning from the apparent delay.

How Do Browser and Node.js Event Loops Compare?

Area Browser Node.js Practical effect
Host work DOM, Web APIs, network, timers libuv, operating system, Node APIs Available APIs differ
Microtasks Promise jobs and browser microtasks Promise jobs plus Node-specific scheduling Priority details differ
Rendering May render between tasks No browser paint cycle UI responsiveness is browser-specific
Immediate callbacks No standard setImmediate() setImmediate() has a check phase Timer ordering is not portable

Browser event-loop behavior

Browsers coordinate JavaScript with DOM events, rendering, user input, networking, and timers. A browser may choose when to render, and different browsers can make different scheduling choices while preserving the required language and platform semantics.

The HTML Standard describes browser tasks and microtask checkpoints. Its model explains why a Promise callback does not interrupt the current event handler, even when the Promise is already resolved.

Node.js phases

Node.js uses libuv phases that include timers, pending callbacks, polling, checking, and close callbacks. The exact behavior depends on the Node.js release and the operation that produced the callback.

Node.js documentation, verified in August 2026, advises treating event-loop phase ordering as an implementation detail when code could run in more than one environment.

setImmediate() versus setTimeout()

In Node.js, setImmediate() runs in the check phase, while setTimeout() runs in the timers phase after its threshold. From the main module, their relative order can vary; inside an input/output callback, setImmediate() is commonly observed first.

Do not use either function when correctness depends on a precise wall-clock delay. Use an explicit timestamp and calculate remaining time when the callback runs.

process.nextTick() versus Promise microtasks

Node.js processes the process.nextTick() queue ahead of the regular microtask queue. Excessive use can delay Promise callbacks, timers, and input/output, so it should remain limited to small, immediate follow-up work.

What Are the Common Event Loop Pitfalls?

  • Blocking the main thread: large loops, synchronous parsing, and expensive regular expressions delay every callback.
  • Assuming setTimeout() is exact: its delay is a lower-bound scheduling threshold.
  • Starving the loop with microtasks: recursive Promise or queueMicrotask() scheduling can postpone tasks indefinitely.
  • Hiding failures in callbacks: unhandled Promise rejections can leave application state inconsistent and may terminate a Node.js process under configured policies.

Blocking the main thread

A synchronous computation that takes 100 ms blocks at least 6 frames on a 60 Hz display, assuming no other delay. Move expensive work away from the browser’s main thread or split it into smaller slices.

Browser extension code can add background listeners and content-script work to the same performance budget. Review browser extension performance penalties when diagnosing unexplained input or page delays.

Why setTimeout() is not exact

A 1,000 ms timer means the callback should not run before its delay has elapsed, subject to host rules. It does not mean the callback runs at exactly 1,000 ms, because the stack may be busy when the timer becomes ready.

Microtask starvation

Repeatedly adding microtasks can prevent the runtime from reaching the next task. Yield with a timer, a browser scheduling API, or a design that processes a bounded batch when low-priority work is competing with input.

Callback nesting and unhandled Promise rejections

Nested callbacks make ownership, cancellation, and error handling difficult. Promise chains and async/await improve structure, but every asynchronous boundary still needs explicit error handling and cancellation behavior.

How Do You Write Event-Loop-Friendly JavaScript?

  • Break up CPU-heavy work: process bounded batches and yield between them.
  • Choose the scheduling mechanism: use requestAnimationFrame() for visual updates, timers for delayed work, and microtasks for short state follow-ups.
  • Use workers for expensive computation: Web Workers isolate browser CPU work, while Node.js worker threads suit CPU-heavy server tasks.
  • Measure delays with profiling tools: inspect long tasks, stack traces, and event-loop delay rather than guessing.

Break up CPU-heavy work

Split a large transformation into chunks with a clear maximum size, then yield so input and rendering can proceed. Measure each chunk because a safe duration depends on the device and the interaction being protected.

Choose the right scheduling mechanism

Do not use a Promise as a general-purpose delay. Microtasks run before later tasks, so a timer or browser frame callback is often a better yield point for background work.

Use workers for expensive computation

Workers keep CPU-intensive calculations off the browser’s main thread, but transferring large objects can introduce its own cost. Use transferable objects or shared memory only when profiling shows that data movement is a bottleneck.

Measure delays with profiling tools

Chrome DevTools can show long tasks and main-thread stacks, while Node.js provides performance hooks and event-loop delay monitoring. Record the Node.js version and browser when reporting timing results because scheduling behavior can change.

For wider architecture decisions, compare event-driven code with the operational constraints described in internal developer platform architecture.

What Is a Practical Mental Model for Debugging Async Code?

Trace the stack, queue, and host API

For every callback, write down the operation that created it, the host API that waited for it, and the queue that will receive it. Then identify the task that must finish before the callback can start.

Identify which queue schedules each callback

Mark Promise reactions and queueMicrotask() as microtasks, timers and DOM events as tasks, and rendering callbacks according to the browser API involved. This exposes most mistakes in assumed JavaScript runtime execution order.

Verify timing assumptions with small experiments

Reduce the case to a few logs, run it in the target browser and Node.js version, and inspect timestamps with performance.now(). Treat an observed order as evidence for that host and context, not as a universal rule for every JavaScript runtime.