JavaScript Event Loop Visualized: Call Stack, Promises, Microtasks & Async/Await
Step through the call stack, runtime APIs, microtasks, tasks, and rendering opportunities—and see why asynchronous JavaScript can still freeze an app.
JavaScript Event Loop Simulator
Open in Engineering LabJavaScript's asynchronous syntax can make several operations look simultaneous. The useful mental model is more precise: JavaScript runs one job until it yields, while its host environment watches timers, network operations, input, and other events. The event loop coordinates when completed work may re-enter JavaScript.
The simulator above is deliberately deterministic. Browsers, Node.js, and native hosts expose different event sources and scheduling phases, but the core ordering rules here explain the behavior developers meet in app code.
The execution model
The call stack contains active function calls. The function at the top is running; calls push frames and returns pop them. Ordinary synchronous JavaScript keeps that stack occupied until the current job completes.
The host supplies capabilities JavaScript itself does not implement: timers, network I/O, user input, file operations, and rendering. Calling setTimeout registers a timer with that host. It does not place the callback directly on the stack and it does not create a second JavaScript execution stack.
When host work becomes eligible, its callback waits in a queue. A simplified browser-oriented model has two important kinds:
- Tasks, sometimes called macrotasks, include the initial script, timer callbacks, and many event callbacks.
- Microtasks include Promise reactions and continuations after await.
After the current job empties the stack, the runtime performs a microtask checkpoint. It drains eligible microtasks before selecting another task. The host may get a rendering opportunity between tasks, subject to its own scheduling rules.
“Macrotask” is common teaching terminology, while browser specifications generally use “task.” The visualizer labels it Task queue to keep the model close to browser terminology.
Why A, D, C, B is correct
Consider the first built-in example:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");A and D execute during the current script task. The resolved Promise queues C as a microtask. The expired timer makes B eligible as a task. Once the script finishes, the microtask checkpoint prints C; only then does the timer task print B.
A
D
C
BA zero-millisecond timer means “not before this delay, and only when the event loop can choose this task.” It never means “run now.”
Promises can extend a checkpoint
A microtask may enqueue another microtask. The runtime continues the checkpoint until the microtask queue is empty. This is why a recursively growing Promise or queueMicrotask chain can postpone timers and rendering even though each callback is technically asynchronous.
Promises also separate settlement from reaction execution. A Promise may already be fulfilled, but a callback passed to then still runs later as a microtask. This guarantee prevents a function from sometimes invoking your callback synchronously and sometimes later depending on cache or timing.
What await actually does
async does not put a whole function on a worker thread. The function begins synchronously. At an await, its current invocation suspends and returns control to the caller. When the awaited Promise settles, the continuation after await is scheduled like a Promise reaction.
async function loadProfile() {
setStatus("loading");
const response = await fetch("/api/profile");
const profile = await response.json();
setProfile(profile);
}Network waiting does not occupy the JavaScript stack. However, code before the first await, JSON parsing, and code after each continuation still run on JavaScript's execution context. Adding async to a CPU-heavy loop does not move that loop elsewhere.
Blocking JavaScript
The event loop cannot interrupt arbitrary synchronous JavaScript to run a timer or input handler. A two-second calculation blocks the stack for roughly two seconds. Ready callbacks remain queued, and the user may see delayed taps, missed animation work, or an unresponsive interface.
Break large work into bounded chunks only when yielding preserves correctness. For genuinely CPU-heavy work, use an appropriate worker or native/background facility and account for the cost of copying, serialization, cancellation, and result delivery.
await Promise.resolve() yields to the microtask queue, not necessarily to a rendering opportunity. A loop that repeatedly awaits already-settled Promises can still starve tasks and paint.
Rendering opportunities are not another queue you control
Browsers normally render between tasks rather than halfway through your function. A state change followed by long synchronous work can therefore update memory immediately while the pixels remain unchanged until the stack and microtasks permit a rendering opportunity.
requestAnimationFrame asks to run work before a future paint. It is not permission to exceed the frame budget. Use it for small presentation updates, not heavy computation.
React and React Native
React event handlers, render calculations, effects, and state updates ultimately consume execution time. React can schedule and prioritize some rendering work, but application code that monopolizes JavaScript still constrains what the runtime can do.
In React Native, the host is not a browser DOM, and modern implementations should not be reduced to the old slogan of “two threads and a bridge.” JavaScript, React scheduling, native modules, layout, graphics, and platform UI work interact through architecture-specific mechanisms. The stable production lesson is simpler: long JavaScript jobs delay JS-driven interactions and state processing, while excessive rendering or native/UI work can miss frame deadlines for different reasons. Continue with React Native Performance Visualized for that frame-budget view, or step back to Processes, Threads and Concurrency for the operating-system foundation.
Common misconceptions
- A Promise runs in parallel. A Promise represents a future result; its JavaScript reaction still needs execution time.
- Every callback is a task. Promise reactions use the microtask mechanism and normally run before the next task.
- A zero-delay timer is immediate. It is only eligible after its delay and waits behind current work and microtasks.
- Async means non-blocking. Waiting can be non-blocking; CPU work inside an async function is still CPU work.
- The event loop makes races impossible. Ordering bugs still occur across callbacks, shared resources, workers, native code, and distributed operations.
Production considerations
Measure before changing architecture. Use platform performance tools to find long tasks, slow renders, repeated state updates, expensive parsing, or work triggered too frequently. Bound queue growth, cancel stale work, avoid unbounded microtask chains, and make loading and error states explicit. A responsive application is not one that uses async everywhere; it is one whose necessary work fits the time available and yields at the right boundaries.
Key takeaways
- Synchronous JavaScript runs to completion on the current call stack.
- Hosts perform timers and I/O, then queue JavaScript callbacks when eligible.
- Microtasks drain after the current job and before the next task.
- await schedules a continuation; it does not make CPU work parallel.
- Asynchronous APIs cannot protect the UI from long JavaScript work.
Related Tutorials
See how processes isolate resources, threads share state, schedules interleave, and synchronization prevents races while creating new tradeoffs.
See how JavaScript, React rendering, native/UI work, lists, images, and state updates compete for a frame—and which optimizations actually help.
Recursion explained as ordinary function calls, pending stack frames, base cases, and a controlled return journey.