Loopscope
JavaScript event loop visualizer
script.js custom code
1
Console output
Call Stack
sync execution
empty
Web APIs
timers · network
nothing pending
Microtask Queue
promises · queueMicrotask
empty
Callback Queue
setTimeout · setInterval
empty
Order: Call Stack runs first, always Microtasks drain fully before anything else then one Callback Queue task runs repeat
speed
Write or pick some JavaScript, then hit Run & Trace.
0 / 0
What this does & current limits
  • Your code runs through a real interpreter built for this tool (not the browser's native scheduler), so the whole run is computed instantly, then replayed step-by-step so you can watch it.
  • Supported: closures, all control flow, destructuring (objects/arrays, with defaults and rest), spread/rest, template literals, classes (constructors, methods, getters/setters, static members, extends/super), labeled break/continue, real Promises (then/catch/finally/all/race/allSettled/any), async/await, setTimeout/setInterval/clearTimeout/clearInterval, queueMicrotask, and the full native standard library (Math, JSON, Array/String/Object methods, etc).
  • fetch is mocked with a simulated network delay — there's no real networking here, just a Promise that resolves after a virtual delay, so its position in the event loop is still accurate.
  • Not supported: generators, private class fields (#x), decorators, modules, and DOM APIs.
  • setInterval auto-stops after 8 firings so traces stay finite.
↓ New to the event loop? Read the full guide below

How the JavaScript Event Loop Actually Works

JavaScript runs on a single thread, yet it juggles timers, network requests, and user clicks without (usually) freezing. The event loop is the reason why. This guide walks through every moving part — the Call Stack, Web APIs, the Microtask Queue, the Callback Queue — from first principles up through the details that trip up experienced developers. Use the visualizer above to run any example live while you read.

The big picture

JavaScript engines run your code on one call stack. There's no thread pool for your functions, no parallel execution of two lines of JS at once. So how does a web page fetch data, run a 3-second timer, and still respond to a click in the meantime?

The answer: JavaScript doesn't actually do the waiting itself. When you call setTimeout, fetch, or read a file in Node, the JS engine hands that work off to something outside itself — the browser's Web APIs (or Node's C++ APIs, backed by libuv) — and immediately moves on to the next line. When that background work finishes, it doesn't jump back into your running code (that would be true parallelism and would corrupt everything). Instead, it drops a message — a callback — into a queue and waits.

The event loop is a simple, tireless referee whose only job is: "Is the call stack empty? If so, is there a queued callback ready to run? If so, put it on the stack." That's it. Everything asynchronous in JavaScript — promises, timers, events, I/O — ultimately reduces to functions waiting in a queue for this referee to notice.

Mental model: the Call Stack is what's running right now. Web APIs are where async work happens out of view. The Microtask Queue and Callback Queue are waiting rooms. The event loop is the only thing that moves work from a waiting room onto the stage.

The Call Stack

The call stack is a simple LIFO (last-in, first-out) structure that tracks which function is currently executing and who called it. Every time a function is invoked, a new frame is pushed on top. When that function returns, its frame is popped off.

function multiply(a, b) { return a * b; }
function square(n)      { return multiply(n, n); }
function printSquare(n) { console.log(square(n)); }

printSquare(5);
// Stack grows: printSquare → square → multiply
// Stack shrinks: multiply returns → square returns → printSquare returns

Nothing else can happen while there's anything on the call stack — no timer fires, no promise callback runs, no click handler executes, no repaint occurs. This is why a long synchronous loop freezes the page: the stack never empties, so the event loop never gets a turn.

Web APIs (and Node APIs)

Functions like setTimeout, setInterval, fetch, DOM event listeners, and geolocation aren't actually part of the JavaScript language — they're provided by the host environment (the browser, or Node's runtime). When you call one, the engine registers the request with the host and returns control to your code immediately; your call stack frame for setTimeout(...) pops right away, long before the timer has actually elapsed.

The host does the actual waiting — counting down a timer, listening on a socket, waiting for a file read — completely outside the JS thread. Once the work is done, the host doesn't run your callback directly either; it places it into the appropriate queue for the event loop to pick up later.

The Callback Queue (macrotasks)

Also called the task queue or macrotask queue. This is where callbacks from setTimeout, setInterval, DOM events, and I/O completions land once their Web API work is finished. The event loop takes one task from here per loop iteration — never more — runs it to completion (which may itself push new things onto the stack, schedule new microtasks, or register new Web API work), and then checks the microtask queue again before touching this queue a second time.

Because only one macrotask runs per iteration, and each is followed by a full microtask drain, macrotasks are the "slow lane" — good for callbacks that can tolerate being deferred, and a natural point where the browser can sneak in a repaint.

The Microtask Queue

This is where .then()/.catch()/.finally() callbacks, await continuations, and queueMicrotask() callbacks land. The critical rule: the microtask queue is always drained completely before the event loop moves on — including any new microtasks scheduled by microtasks that are currently running. If a microtask schedules another microtask, that one runs too, before any macrotask gets a turn.

Promise.resolve().then(() => {
  console.log('first');
  Promise.resolve().then(() => console.log('spawned by first'));
});
Promise.resolve().then(() => console.log('second'));
// Output: first, spawned by first, second
// — the microtask spawned by "first" still runs before "second"'s own
//   turn was already queued, but strictly, all pending microtasks drain
//   in FIFO order, and freshly-added ones join the same queue.

This is why microtasks are the "fast lane": a chain of promises can, in principle, keep the event loop from ever reaching a macrotask (this is a real bug pattern — see Common mistakes).

The event loop algorithm, step by step

Put together, one full pass looks like this:

  1. Run all synchronous code from top to bottom (the initial script is itself treated like a task).
  2. Call stack is now empty. Drain the microtask queue completely — run every pending microtask, including ones added while draining — until it's empty.
  3. If the browser has scheduling headroom, it may perform a rendering update here (see Where rendering fits in).
  4. Take the single oldest task from the callback queue (if any) and run it on the call stack.
  5. Go back to step 2.

This loop runs forever, as fast as it can when there's work, and idles when both queues and all pending Web API work are empty.

Reading the visualizer's steps

When you click Run & Trace above, your code is executed once instantly against a virtual scheduler, producing a full timeline of discrete steps. Stepping through with the transport controls replays that timeline. Here's what each kind of step means:

StepWhat happened
Call Stack pushA function call just started and was placed on top of the Call Stack. It runs before anything below it can continue.
Call Stack popA function finished (hit return or ran out of statements) and was removed from the stack. Control returns to whoever called it.
Web APIs — addedAn async operation (setTimeout, setInterval, fetch) was handed off to run outside the call stack.
Callback Queue — addedA Web API finished its background work and moved its callback into the Callback Queue, where it waits its turn.
Callback Queue — runningThe Call Stack and Microtask Queue were both empty, so the event loop pulled the oldest callback here and started running it.
Microtask Queue — addedA Promise settled (or queueMicrotask was called), scheduling a reaction callback.
Microtask Queue — runningThe event loop pulled the next microtask and ran it. This always happens before the next Callback Queue task.

The current line of your code is highlighted in the editor at each step where that's meaningful (function calls, scheduling calls, console output), so you can follow along token-by-token.

async/await, demystified

async/await is syntax sugar over promises — it doesn't introduce a new concurrency model. An async function always returns a promise. Inside it, await expr does three things: it evaluates expr, wraps the result in a promise if it isn't already one, suspends the function and immediately returns control to the caller, and schedules the rest of the function to resume as a microtask once that promise settles.

async function run() {
  console.log('A');
  await null;       // suspends here — control returns to the caller now
  console.log('B'); // resumes later, as a microtask
}
console.log('start');
run();
console.log('end');
// Output: start, A, end, B

Notice that calling run() does not block — the synchronous part of the function (up to the first await) runs immediately, and everything after resumes later. This is exactly what you'd see if you rewrote it with raw .then() chains; async/await just reads top-to-bottom instead of nesting callbacks.

Where rendering fits in (browsers only)

Browsers try to repaint the screen around 60 times per second, but a repaint can only happen when the call stack is empty — the same rule that governs everything else. In practice, the browser looks for an opportunity between macrotasks (roughly: after a task and its resulting microtasks are done) to run style/layout/paint if a frame is due. requestAnimationFrame callbacks are scheduled to run right before that paint step, making them the right tool for visual updates — unlike setTimeout, which has no awareness of the frame schedule at all.

Node.js vs. the browser

Node.js has its own event loop, implemented by libuv, and it's more elaborate than the browser's. Instead of one generic callback queue, Node cycles through named phases each iteration: timers (setTimeout/setInterval), pending callbacks, poll (I/O), check (setImmediate), and close callbacks. Microtasks (promises) and, uniquely to Node, process.nextTick callbacks are drained between every phase — and process.nextTick jumps the queue ahead of even regular promise microtasks. The high-level rule from this guide still holds — synchronous code first, then microtasks fully drained, then one macrotask-ish thing at a time — but the exact phase ordering is Node-specific and worth a dedicated read if you're debugging server-side timing bugs.

Common mistakes & gotchas

Blocking the main thread

A tight synchronous loop or heavy computation blocks everything — timers, promise callbacks, clicks, scrolling, painting — because none of it can run until the call stack empties. There's no such thing as a JS engine "interrupting" your running function to squeeze in a timer.

Microtask starvation

Because the microtask queue must fully drain before the next macrotask, a promise reaction that keeps re-scheduling itself (e.g. an unbounded .then() recursion) can starve macrotasks — and rendering — indefinitely, even though each individual microtask finishes quickly.

Assuming setTimeout(fn, 0) means "right now"

It means "as soon as possible, but strictly after the current synchronous code and every pending microtask." If you schedule a promise and a zero-delay timeout back to back, the promise callback always wins.

Off-by-one thinking about await

Every await, even await on an already-resolved value, defers the rest of the function by at least one microtask turn. Code after await somePromise never runs synchronously with the code that called the async function, even if the promise was already settled.

Forgetting timers keep objects alive

An active setInterval (or an unresolved promise chain referencing a closure) keeps everything that closure captured reachable, which is a common source of memory leaks in long-running pages or servers.

Glossary

Call Stack
The single, LIFO structure tracking which function is currently running and its chain of callers.
Heap
The region of memory where objects, arrays, and functions are actually allocated; the stack holds references into it.
Web API
Functionality provided by the browser (or Node runtime) outside the JS language itself — timers, network, DOM events, file I/O.
Task / Macrotask
A unit of work queued in the Callback Queue — one runs per event loop iteration.
Microtask
A higher-priority queued callback (promise reactions, queueMicrotask) that fully drains before the next macrotask.
Event Loop
The process that repeatedly checks whether the call stack is empty and, if so, moves the next queued callback onto it.
Starvation
When one queue (typically microtasks) keeps generating more work for itself, indefinitely delaying another queue's turn.
Blocking
Running synchronous code long enough that the call stack can't empty, freezing all async work and rendering.

Frequently asked questions

What is the JavaScript event loop?

It's the mechanism that lets single-threaded JavaScript handle asynchronous work: it continuously checks whether the call stack is empty, and when it is, moves queued callbacks — all pending microtasks first, then a single macrotask — onto the stack to run.

Do microtasks or macrotasks run first?

Microtasks always run first, and completely — every pending promise reaction and queueMicrotask callback, including new ones scheduled while draining — before the event loop is allowed to start the next macrotask.

Is setTimeout(fn, 0) really immediate?

No. It still waits for the current call stack to finish and for the microtask queue to fully drain, so it always runs after synchronous code and any pending promises — even ones scheduled after it.

How does async/await relate to the event loop?

It's syntax sugar over promises. Each await pauses the function and immediately returns control to the caller with a pending promise; when the awaited value settles, the rest of the function resumes as a microtask.

Why can a JavaScript app freeze even though it's "asynchronous"?

Because there's only one call stack. Long-running synchronous code blocks it completely, so no timers, promise callbacks, UI events, or rendering can happen until it finishes.

↑ Back to the visualizer