The JavaScript Event Loop: Call Stack, Microtasks, Macrotasks, and Everything Between

JavaScript can start a timer, request data over the network, respond to a click, and update the screen—yet it normally executes only one piece of JavaScript at a time on the browser's main thread.
That sounds contradictory only when “JavaScript” and “the JavaScript runtime” are treated as the same thing. They are not.
The JavaScript engine executes language instructions. The browser supplies timers, networking, DOM events, rendering, and an event loop that decides when JavaScript callbacks may run. Node.js supplies a different host environment with its own I/O system and event-loop phases. Asynchronous JavaScript emerges from the cooperation between the engine and its host.
This article builds a mental model precise enough to answer questions such as:
- Why does
setTimeout(fn, 0)run later? - Why does a resolved Promise run before that timer?
- Where does the code after
awaitgo? - Why can an endless chain of Promises freeze the page?
- When can the browser paint?
- Why does Node.js sometimes order callbacks differently from a browser?
If the engine side is still fuzzy, first read What Actually Happens When JavaScript Runs? From Source Code to Execution. It follows source code through parsing, bytecode, optimization, and execution. Here we begin at the point where executable JavaScript must share time with the rest of the runtime.
The shortest useful mental model
For browser JavaScript, remember this cycle:
- Select and run one task.
- Keep executing synchronous function calls until the call stack is empty.
- Drain the microtask queue completely.
- The browser may update rendering.
- Repeat.
In compact form:
one task
-> synchronous call stack
-> all queued microtasks
-> possible rendering opportunity
-> next taskThis model predicts most application-level behavior. The full platform is more nuanced: the HTML Standard defines multiple task queues and task sources, and the browser may choose among runnable tasks while preserving ordering within a task source. So “the macrotask queue” is useful teaching shorthand, not a literal description of every browser implementation.
The engine, the host, and the event loop
An event loop is not a magical component hidden inside the JavaScript language. ECMAScript defines execution contexts and Jobs, including the Jobs used for Promise reactions. A host such as a browser or Node.js connects those language mechanisms to timers, I/O, user input, and scheduling.
A simplified browser runtime contains:
| Part | Responsibility |
|---|---|
| JavaScript engine | Parses and executes JavaScript; manages execution contexts and memory |
| Call stack | Tracks the currently executing chain of functions |
| Web APIs | Provide timers, networking, DOM events, observers, and other browser capabilities |
| Task queues | Hold runnable work such as timer callbacks and event dispatch |
| Microtask queue | Holds high-priority follow-up work such as Promise reactions |
| Rendering pipeline | Calculates styles and layout, paints, and composites frames |
| Event loop | Coordinates when tasks, microtasks, and rendering work may proceed |
MDN's JavaScript execution model describes the same separation as an engine working with a host environment. That boundary matters: Promise is part of JavaScript, but setTimeout, fetch, and the DOM are host APIs.
The call stack: what is running right now?
Whenever JavaScript enters a function, the engine pushes a frame onto the call stack. When the function returns, its frame is popped. Because the stack is last-in, first-out, the most recently entered function finishes first.
function getLabel() {
return "Event loop";
}
function printLabel() {
console.log(getLabel());
}
printLabel();The stack evolves approximately like this:
global script
global script -> printLabel
global script -> printLabel -> getLabel
global script -> printLabel
global script
emptyTwo consequences are fundamental.
First, JavaScript uses run-to-completion for each job or callback. Another task cannot interrupt a function halfway through and run arbitrary JavaScript on the same thread. The current work keeps control until it returns and the stack unwinds.
Second, asynchronous callbacks cannot run merely because their timer expired or network request completed. They must wait until the current stack is empty and the host schedules them.
Run-to-completion makes local reasoning easier, but it also creates the main-thread performance problem: a function that runs for 500 ms blocks click handlers, timers, and rendering for roughly the same period.
Tasks, commonly called macrotasks
The HTML specification calls them tasks. “Macrotask” is an informal term used to contrast tasks with microtasks.
Typical sources of browser tasks include:
- running a script for the first time;
- dispatching a click, keyboard, or other DOM event;
- running a ready
setTimeoutorsetIntervalcallback; - processing certain networking and messaging events;
- handling a
MessageChannelmessage.
The event loop runs a selected task to completion. Only after that task finishes can the browser perform a microtask checkpoint and consider rendering or another task.
Why setTimeout(fn, 0) is not immediate
setTimeout does two things:
- It asks the host to wait until at least the requested delay has elapsed.
- Once eligible, it causes the callback to be queued as a task.
It does not push the callback onto the call stack, and the delay is not a guaranteed execution time.
console.log("first");
setTimeout(() => {
console.log("timer");
}, 0);
console.log("second");Output:
first
second
timerThe global script is already the current task. It must finish. Then the runtime drains any microtasks, may render, and only later selects the timer task. Busy main-thread work, other queued tasks, operating-system scheduling, nested-timer clamping, and background-tab throttling can delay it further. MDN's setTimeout() documentation documents these reasons in detail.
Think of the delay as not before this threshold, never exactly at this time.
Microtasks: follow-up work before the next task
Microtasks exist for work that should happen after the current JavaScript finishes but before the runtime moves on to another task.
Common microtask sources include:
- Promise reaction handlers registered with
.then(),.catch(), or.finally(); - continuation of an
asyncfunction afterawait; - callbacks passed to
queueMicrotask(); MutationObservercallbacks in browsers.
After a task leaves the call stack empty, the event loop performs a microtask checkpoint. It runs the oldest microtask, then the next, and continues until the queue is empty. If a microtask queues another microtask, the new one joins the same checkpoint and also runs before the next task.
That last rule is the reason microtasks feel “higher priority” than timers. It is also why careless microtask recursion can starve everything else.
console.log("A");
setTimeout(() => console.log("task"), 0);
Promise.resolve().then(() => console.log("promise microtask"));
queueMicrotask(() => console.log("queued microtask"));
console.log("B");Output:
A
B
promise microtask
queued microtask
taskThe Promise was already fulfilled, but .then() still did not run synchronously. It queued a reaction. That reaction was queued before the explicit queueMicrotask, so first-in ordering within the microtask queue preserved their order.
The MDN microtask guide and the HTML Standard's microtask checkpoint algorithm are the best references when a scheduling edge case needs specification-level confirmation.
A Promise executor is synchronous; its handlers are not
This distinction causes many wrong predictions:
console.log("start");
new Promise((resolve) => {
console.log("executor");
resolve();
}).then(() => {
console.log("handler");
});
console.log("end");Output:
start
executor
end
handlerThe function passed to the Promise constructor executes immediately. Calling resolve() changes the Promise's state and arranges reactions; it does not synchronously call .then() handlers. The handler runs as a microtask after the current stack empties.
Microtasks can schedule more microtasks
queueMicrotask(() => {
console.log("microtask 1");
queueMicrotask(() => {
console.log("microtask 3");
});
});
queueMicrotask(() => {
console.log("microtask 2");
});Output:
microtask 1
microtask 2
microtask 3The newly queued callback goes to the end of the microtask queue, but the event loop still drains it before selecting a new task.
One complete execution-order walkthrough
Predict the output before reading the explanation:
console.log("1: script start");
setTimeout(() => {
console.log("6: timer");
}, 0);
Promise.resolve()
.then(() => {
console.log("3: promise one");
})
.then(() => {
console.log("5: promise two");
});
queueMicrotask(() => {
console.log("4: explicit microtask");
});
console.log("2: script end");The output is:
1: script start
2: script end
3: promise one
4: explicit microtask
5: promise two
6: timerHere is the timeline:
- The initial script runs as a task and logs
1. setTimeoutregisters a timer. Its callback cannot run in the current task.- The first
.then()reaction is added to the microtask queue because its Promise is fulfilled. queueMicrotaskadds its callback behind that first reaction.- The script logs
2and finishes. - The first Promise reaction logs
3. Returning from it fulfills the Promise created by the first.then(), which queues the second reaction behind the microtask already waiting. - The explicit microtask logs
4. - The second Promise reaction logs
5. - The microtask queue is empty. The timer task can eventually be selected and logs
6.
Do not memorize the output. Reconstruct the queues. That method still works when the example contains real API calls instead of console statements.
What async and await actually schedule
An async function begins synchronously. Reaching await suspends that function, returns a Promise to its caller, and arranges for the remainder of the function to continue later. When the awaited value is ready, that continuation runs through Promise-job machinery—observed in browsers as microtask scheduling.
async function load() {
console.log("inside: before await");
await null;
console.log("inside: after await");
}
console.log("outside: start");
load();
console.log("outside: end");Output:
outside: start
inside: before await
outside: end
inside: after awaitEven await null creates an asynchronous boundary. The code before await runs on the current stack; the continuation is deferred.
With fetch, the browser can perform network work without keeping JavaScript on the call stack. Calling fetch() returns a Promise. When the host eventually settles it, the relevant Promise reactions become microtasks. None of this makes CPU-heavy JavaScript parallel: a large calculation before or after await still occupies the same JavaScript thread.
One particularly useful rule follows:
await Promise.resolve()yields to microtasks, not necessarily to rendering or user input.
If the goal is to let the browser handle another task and potentially paint, repeatedly awaiting already-resolved Promises is the wrong tool.
Rendering lives beside the queues, not inside them
The browser's main thread does more than execute JavaScript. It also handles style calculation, layout, paint preparation, and parts of compositing and input dispatch.
A useful simplified order is:
task -> microtask checkpoint -> possible rendering update -> next taskThe word possible matters. The browser does not have to paint after every task. A display has frame deadlines, a document may be hidden, and there may be nothing visually new to render.
Why DOM changes can be invisible during a long function
status.textContent = "Working…";
const start = performance.now();
while (performance.now() - start < 2000) {
// Simulate expensive synchronous work.
}
status.textContent = "Done";Users may never see “Working…”. The script keeps the current task alive for two seconds, changes the text again, and only then returns control. The browser could not render between those two assignments.
Where requestAnimationFrame fits
requestAnimationFrame(callback) asks the browser to invoke a callback before a future repaint. It is coordinated with rendering rather than behaving like a normal timer. The callback is one-shot, and the browser may pause it in hidden documents. Use its timestamp to make motion independent of display refresh rate. See MDN's requestAnimationFrame() reference.
A common “show loading state, then work” pattern is:
status.textContent = "Working…";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
doExpensiveWork();
status.textContent = "Done";
});
});The double callback can provide a rendering opportunity for the first visual change before work begins, although production code should usually split or move the expensive work rather than deliberately blocking a later frame.
Starvation: when priority becomes a bug
Because the browser drains microtasks until the queue is empty, an infinite microtask chain can prevent timers, input, and rendering from progressing.
function starve() {
queueMicrotask(starve);
}
starve();
setTimeout(() => {
console.log("This may never run");
}, 0);Each microtask adds another before the checkpoint can finish. The stack repeatedly empties, but the microtask queue never does.
The same failure can appear less obviously when application code processes a huge collection through a recursive Promise chain. “Asynchronous” syntax does not guarantee fairness. If every continuation stays in the microtask queue, the browser still cannot reach rendering or the next task.
Tasks can also cause starvation by simply running too long. web.dev classifies main-thread tasks over 50 ms as long tasks. During that work, interaction handlers must wait, which can damage Interaction to Next Paint (INP) and make the interface feel frozen.
How to keep the main thread responsive
The event loop is cooperative. Your code must return control often enough for other work to run.
1. Do less work
Avoid repeated calculations, unnecessary DOM measurement, oversized client-side transformations, and excessive third-party JavaScript. The fastest task is the one you remove.
2. Split CPU work into bounded chunks
function processInChunks(items, chunkSize = 500) {
let index = 0;
function runChunk() {
const end = Math.min(index + chunkSize, items.length);
while (index < end) {
processItem(items[index]);
index += 1;
}
if (index < items.length) {
setTimeout(runChunk, 0);
}
}
runChunk();
}Each timer starts a later task, allowing input and rendering opportunities between chunks. Tune by elapsed time rather than item count when per-item cost varies.
Modern browsers also offer scheduler.yield() as a purpose-built way to yield with a prioritized continuation. Because support is not universal, use feature detection and a fallback as described in web.dev's guide to optimizing long tasks.
3. Move heavy computation to a Web Worker
Chunking improves responsiveness but does not reduce total CPU work. For parsing large datasets, image processing, simulations, or other independent computation, a Web Worker can run JavaScript in a separate agent with its own event loop. Workers cannot directly manipulate the DOM, so communicate through messages and keep UI updates on the main thread.
4. Match the scheduling tool to the job
| Goal | Appropriate tool |
|---|---|
| Run after the current stack, before the next task | queueMicrotask() or a Promise reaction |
| Run no earlier than a delay | setTimeout() |
| Coordinate visual updates with a repaint | requestAnimationFrame() |
| Yield long work so input/rendering can proceed | scheduler.yield() with a fallback, or task-based chunking |
| Move CPU-heavy work off the main thread | Web Worker |
Microtasks are for short consistency work, not for breaking up expensive processing.
Browser event loop vs Node.js event loop
The language is the same, but the host is different. Browser scheduling revolves around tasks, microtasks, DOM events, and rendering. Node.js uses libuv and organizes event-loop work into phases.
A simplified Node.js phase sequence includes:
timers
-> pending callbacks
-> poll
-> check
-> close callbacks
-> repeatImportant Node-specific details include:
- I/O callbacks are primarily handled around the poll phase.
setImmediate()callbacks run in the check phase.- Timer callbacks run after their minimum threshold and when the loop reaches the appropriate phase; the threshold is not an exact deadline.
process.nextTick()uses a special queue that is processed before the event loop continues. Recursive use can starve I/O.- Promise reactions and
queueMicrotask()use the microtask machinery, butprocess.nextTick()has Node-specific ordering semantics. - Since libuv 1.45.0, used by Node.js 20, timers run after the poll phase in each loop iteration, a change that can affect interaction with
setImmediate().
The official Node.js event-loop guide explains the phases and their version-sensitive behavior. Do not apply a browser mnemonic blindly to Node.js, and do not assume setTimeout(..., 0) versus setImmediate() has one universal order—the context matters.
Common misconceptions corrected
“JavaScript is asynchronous”
JavaScript execution is ordinarily synchronous and run-to-completion. The host provides asynchronous capabilities and schedules JavaScript callbacks when those operations progress.
“A zero-millisecond timer runs immediately”
Zero is a minimum delay request, not permission to interrupt current code. The callback becomes a future task.
“Promises run in parallel”
Promise handlers are scheduled; they do not create a parallel JavaScript thread. The underlying operation may happen elsewhere, but its JavaScript reaction still runs on an appropriate agent's stack.
“Microtasks always run first”
Synchronous code on the current stack runs first. Microtasks run when the runtime reaches a checkpoint, generally after current JavaScript unwinds. They run before the next task, not before the current one.
“The event loop is a single FIFO queue”
Browsers can have multiple task queues associated with different task sources. Ordering is constrained, but user agents have scheduling freedom. The single-queue picture is a learning model.
“await makes CPU-heavy code non-blocking”
await suspends an async function while waiting. Any expensive synchronous section on either side of it still blocks the JavaScript thread that executes it.
A repeatable way to predict output
When an interview question or production bug mixes timers, Promises, and event handlers, use this process:
- Identify the current task.
- Execute synchronous statements in call-stack order.
- Record every newly queued microtask in exact order.
- Record future tasks separately, noting that timer delays are thresholds.
- When the stack empties, drain microtasks completely, including new ones they create.
- Consider whether rendering has an opportunity.
- Select the next runnable task and repeat.
- If the runtime is Node.js, account for phases and
process.nextTick().
Write the queues on paper if necessary. Most “trick” questions stop being tricks once task and microtask scheduling are tracked separately.
Debugging event-loop problems in real applications
Console output is helpful for tiny examples, but production timing bugs need better evidence.
- Use the browser Performance panel to record long tasks, event handlers, timer callbacks, layout, paint, and frame gaps.
- Use
performance.mark()andperformance.measure()to add application-specific timing boundaries. - Watch for long red-marked tasks and dense chains of Promise callbacks.
- Test with CPU throttling; a harmless desktop task may become a serious mobile delay.
- Inspect whether a supposed “yield” only schedules another microtask.
- In Node.js, use the inspector, flame graphs, and event-loop delay/utilization metrics when callbacks block server progress.
Always debug the host you actually deploy to. Browser, Node.js, workers, and test runners can expose different scheduling behavior.
Frequently asked questions
What is the JavaScript event loop in one sentence?
It is the host's scheduling mechanism that lets JavaScript run queued work when the current stack is clear while coordinating asynchronous operations and, in browsers, rendering.
What is the difference between a microtask and a macrotask?
A microtask runs during a checkpoint after current JavaScript finishes and before the next task. A macrotask—more accurately, an HTML task—is a separately scheduled unit such as a timer callback or event dispatch.
Do Promise callbacks always run before setTimeout callbacks?
When both become eligible from the same current task, Promise reactions run at the microtask checkpoint before a later timer task. Different turns, environments, or additional scheduling can make broader comparisons more complex.
Does await block the event loop?
Waiting at await does not block it; the async function is suspended. Synchronous work performed before suspension or after continuation can still block.
Can microtasks freeze a page?
Yes. A microtask that continually queues another microtask can prevent the checkpoint from ending, starving rendering, input, and tasks.
Is the browser event loop the same as Node.js?
No. Both integrate JavaScript Jobs with host scheduling, but browsers coordinate task sources and rendering while Node.js uses libuv phases and Node-specific queues such as process.nextTick().
Final mental model
The event loop is easier to understand when every component has one job:
- The call stack answers: what JavaScript is executing now?
- A task starts a new unit of host-scheduled work.
- A microtask performs short follow-up work before the next task.
- The browser may render only after JavaScript gives it an opportunity.
- The event loop coordinates those transitions.
- Node.js applies the same JavaScript language inside a different scheduling host.
The most important rule is not “Promises beat timers.” It is this:
Current JavaScript runs to completion, then microtasks drain, then the host can move to rendering and other tasks.
Once that sequence is clear, Promise, async/await, timers, events, and UI responsiveness stop looking like unrelated features. They become different ways of placing work into a runtime whose scheduling rules you can inspect, predict, and design around.

Discussion
Sign in to join the discussion.