JavaScript Execution Model

Most explanations of the JavaScript runtime stop at the call stack, event loop, and a queue. That's enough for beginners, but senior engineers know there's more happening under the hood: scheduling layers, host-driven queues, microtask semantics, async stack reconstruction, and cross-runtime behavioural differences that decide whether your production code works smoothly… or unpredictably.

This post explains the execution model as it actually works today across browsers, Node, and frameworks like React. If you already know the basics, this is the layer beneath.

A Better Mental Model: "Execution Pipelines", Not "The Event Loop"

The event loop diagram you've seen (stack → queues → loop) hides one critical truth:

JavaScript doesn't control scheduling — the host does.

Browsers, Node, React, and libraries each add their own pipelines, with their own queues and semantics. JavaScript is only the language sitting inside those systems.

A more accurate analogy:

Think of JS as a chef that cooks one dish at a time. The kitchen (browser/Node/React) decides the order in which dishes arrive. Microtasks are "urgent notes" slipped into the chef's hand between dishes.

What Actually Happens During Execution

1. Call Stack Execution

Normal synchronous execution. Nothing new here.

2. Microtask Queue Flush (Per Turn)

After sync code completes, the runtime drains the microtask queue to completion before moving on.

Microtasks include:

  • Promise .then callbacks
  • queueMicrotask
  • MutationObserver callbacks
  • (In Node) process.nextTick

3. Macrotask Queue Execution

Once microtasks are flushed, the host picks a new macrotask:

  • setTimeout
  • setInterval
  • DOM events
  • I/O callbacks
  • MessageChannel
  • Node's setImmediate
  • fetch → .then (Promise) is microtask, but network completion enqueues a macrotask

4. Rendering Pipeline (Browsers Only)

Browsers may render between macrotasks, but microtasks can postpone rendering.

This 4-stage cycle is far more nuanced than the simple "event loop" most people reference.

Microtasks vs Macrotasks: Real-World Use Cases

Microtasks (ultra-priority)

Use when you must:

  • Run logic immediately after current JS finishes
  • Batch mutations before layout
  • Guarantee sequencing regardless of timers or rendering
doWork();
queueMicrotask(() => {
  // Runs *before* the browser has a chance to paint
  updateDOMState();
});

Macrotasks (yielding to the host)

Use when:

  • You want to let rendering happen first
  • You want to allow other events (like input) to process
  • You need predictable scheduling across environments
setTimeout(() => {
  // Browser may render before this
  hydrateUI();
});

Subtle difference that bites senior devs:

setTimeout(() => console.log("macro"));
Promise.resolve().then(() => console.log("micro"));
// Output: micro, macro

But in Node: process.nextTick runs before microtasks, giving a third priority level.

This is why porting browser code to Node (or vice-versa) can break sequencing.

Scheduler Behaviour: How Hosts Actually Decide Order

1. Browsers

Use a multi-queue model:

  • timers
  • input
  • networking
  • resize/scroll
  • idle tasks (requestIdleCallback)
  • rendering

The browser picks the next macrotask from various queues. Timers may be delayed by clamped timeouts, background throttling, or rendering constraints.

2. Node.js

Node's event loop (libuv) has six phases, each with its own queue:

  1. timers
  2. pending callbacks
  3. idle/prepare
  4. poll
  5. check (setImmediate)
  6. close callbacks

And Node has a microtask checkpoint between every phase, not just at end of a turn.

So this:

setTimeout(console.log, 0, "timeout");
setImmediate(() => console.log("immediate"));

can yield different orders depending on I/O timing.

3. React (Concurrent Rendering)

React isn't "inside" the JS event loop. It builds its own scheduler (inspired by cooperative multitasking):

  • Breaks work into units
  • Pauses between units to let the browser handle urgent events
  • Uses MessageChannel to schedule ticks
  • Uses microtasks sometimes, macrotasks other times, to maintain order guarantees

React's scheduler can preempt rendering within a single JavaScript macrotask, something plain JS cannot do.

Edge Cases with Async Stack Traces

JavaScript engines reconstruct async stack traces by:

  • Capturing the sync stack at await or Promise resolution
  • Storing continuation frames elsewhere
  • Reassembling stack when errors occur

But this reconstruction isn't perfect:

  • Some stacks don't propagate across host boundaries (e.g., DOM events)
  • Some frameworks add their own layers (React Fiber)
  • Node's async stack traces can hide libuv transitions
async function a() {
  await b();
}
async function b() {
  await c();
}
async function c() {
  throw new Error("Oops");
}

Useful analogy: Async stack traces are like crime scene reconstructions. They show the story, not the exact reality.

Promise Internals: How They Really Work

Promises aren't just "microtasks with sugar". Internally:

  1. Promise creation allocates a reaction record
  2. Resolution enqueues a job (microtask)
  3. Jobs are called with their associated capability
  4. Errors are tracked separately via "unhandled rejection" observers in host queues

Promises create guaranteed sequencing:

  • .then callbacks are always async
  • .then callbacks run before any macrotask
  • Errors inside .then propagate through chained reactions

This is why:

await 0;
console.log("after await");

behaves like a microtask break, not a timer.

Why "Event Loop" Explanations Are Oversimplified

The classic explanation misses:

  • That hosts maintain multiple queues, not one
  • That JS engines create microtasks per turn, not per loop cycle
  • That Node has phases, not a single queue
  • That browsers may skip rendering due to microtasks
  • That React is a host scheduler, not just "JS running inside the browser"
  • That async stack traces are virtualized

If you've ever thought "why does this behave differently in Chrome vs Node vs React?", this is why.

Practical Consequences in Real Apps

1. Microtask storms can block rendering

while (true) {
  await Promise.resolve(); // Never yields to render
}

This pattern will freeze the UI with 0 CPU availability for input.

2. Excessive microtasks starve timers

A burst of Promises can delay a setTimeout by hundreds of milliseconds.

3. React's scheduler may reorder work vs your assumptions

State updates inside React events sometimes flush synchronously, sometimes not, depending on the priority lane.

4. Node sequencing bugs in scripts

nextTick is notorious for causing starvation:

process.nextTick(() => {
  process.nextTick(() => {
    process.nextTick(() => console.log("still going"));
  });
});

This runs before all microtasks and can easily block I/O.

5. Async stack traces hide performance cliffs

Large promise chains result in artificial stack traces that don't show allocation hotspots.

Analogies That Stick

Microtasks: "Small urgent notes delivered to the chef before he starts the next dish."

Macrotasks: "A new order ticket placed on the counter. The chef looks at it only after finishing all notes."

React Scheduler: "A head chef deciding which dishes should be fast-tracked or paused mid-prep so customers don't wait too long."

Async Stacks: "Security camera footage reconstructed into a timeline — accurate enough, but heavily edited."

Wrapping Up

JavaScript execution isn't about "the event loop". It's about how multiple schedulers cooperate:

  • The JS engine
  • The browser or Node host
  • The microtask mechanism
  • Framework schedulers (React, Vue's nextTick, Svelte's job queues)

Mastering real-world asynchronous behaviour means reasoning about how these layers interact, not just memorizing queue priorities.