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.
