One Thread, Many Tasks: The JS Execution Model

Event Loop,
Web APIs &
Microtask Queue

How the browser enables non-blocking, asynchronous JavaScript

Ibrahim GUOUAL

the problem

JavaScript is single-threaded

one Call Stack · one task at a time # a slow task blocks everything else

Imagine a 30-second network request on the Call Stack.
The entire page freezes. No clicks. No scrolling.

fetch("https://api.example.com/data")
// Would this block the Call Stack until data returns?
// That would freeze the entire UI…

Luckily, no. That's what Web APIs are for.

concept 1

The Call Stack execution context

function greet(name) {
  return `Hello, ${name}!`
}

function respond() {
  return greet("Lydia")
}

respond()

Each function call pushes a new execution context.
When it returns, it pops off the stack.

Call Stack (LIFO)

  • 1 respond() pushed → calls greet()
  • 2 greet("Lydia") pushed → returns string
  • 3 greet popped off
  • 4 respond popped off
  • 5 Stack is empty

concept 2

Web APIs offload work to the browser

Web APIs are a bridge between the JS runtime and browser features. They allow us to hand off long-running tasks, and free up the Call Stack immediately.

JS Runtime
Call Stack

Heap
(memory)
Web APIs
fetch / XHR
setTimeout
Geolocation
DOM events
…and more
Browser
Network stack
Rendering engine
Sensors / Camera
Timers

Invoking a Web API is just registering a task with the browser, the execution context pops off the Call Stack right away.

concept 3

Callback-based APIs → Task Queue

// Geolocation API, callback-based
navigator.geolocation.getCurrentPosition(
  (position) => {
    console.log(position) // successCallback
  },
  (error) => {
    console.error(error)  // errorCallback
  }
)
// setTimeout, also callback-based
setTimeout(() => {
  console.log("Timer done!")
}, 1000)
// delay = time until pushed to Task Queue
// NOT guaranteed execution time

What happens

  • 1Function is pushed to Call Stack
  • 2Browser takes over the async task
  • 3Call Stack is freed immediately
  • 4When done → callback → Task Queue

⚠️ A setTimeout(fn, 0) still goes through the Task Queue, the callback runs after any currently running code.

concept 4

The Event Loop the conductor

while (true) { if (callStack.isEmpty()) moveFromQueue() } # continuously checks: is the call stack empty?
  • Call Stack is empty? Event Loop picks the first task from the Task Queue.
  • Moves it onto the Call Stack, it executes.
  • Task completes, pops off. Event Loop checks again.
  • This loop runs continuously for the lifetime of the page.

The Event Loop doesn't execute code. It just moves tasks from queues to the Call Stack.

concept 5

Promise-based APIs → Microtask Queue higher priority

fetch("https://api.example.com/data")
  .then(res => res.json())   // → Microtask Queue
  .then(data => console.log(data))
① Microtask Queue Promise handlers (.then, .catch, .finally), async/await continuations, MutationObserver, queueMicrotask
→ ALL microtasks are drained before moving on
② Task Queue Callback-based Web APIs: setTimeout, setInterval, event listeners, requestAnimationFrame
→ Processed one task at a time

⚠️ Microtasks can schedule more microtasks → infinite microtask loop is possible. This cannot happen with the Task Queue.

challenge

What's the output order?

console.log("Start")          // 1

setTimeout(() => {
  console.log("Timeout")      // ?
}, 0)

Promise.resolve()
  .then(() => {
    console.log("Promise")    // ?
  })

console.log("End")            // ?

Output

  • "Start" sync, Call Stack
  • "End" sync, Call Stack
  • "Promise" Microtask Queue (drained first)
  • "Timeout" Task Queue (runs after)

Even with setTimeout(..., 0), the Promise handler always wins it's in the higher-priority Microtask Queue.

recap

The full picture

JS Runtime
Heap
Call Stack
Sync execution
LIFO order
One task at a time
Web APIs
fetch
setTimeout
Geolocation
DOM
Offload to browser
Callback → queues inside Web APIs
Event Loop
Watches the call stack.
Task Queue
setTimeout · setInterval · event callbacks
One task per tick
Microtask Queue
Promise handlers · async/await · queueMicrotask
Drained first

takeaways

What to remember

Core rules

  • JS is single-threaded, one task at a time
  • Web APIs offload async work to the browser
  • Event Loop runs when Call Stack is empty
  • Microtask Queue drains completely first
  • Task Queue runs one task, then checks microtasks

Common gotchas

  • setTimeout(fn, 0) ≠ immediate execution
  • Infinite microtask loops freeze the page
  • Long sync tasks block callbacks
  • Promise handlers always beat setTimeout callbacks

"JavaScript isn't async by itself.
The browser is."