Skip to content

JavaScript Event Loop — Microtasks, Macrotasks, and Asynchronous Execution

DodaTech Updated 2026-06-29 5 min read

In this tutorial, you will learn about JavaScript Event Loop. We cover key concepts, practical examples, and best practices to help you master this topic.

The event loop is the beating heart of JavaScript's concurrency model. Despite being single-threaded, JavaScript handles thousands of asynchronous operations through an elegant coordination of the call stack, task queues, and microtask queues. Understanding this model is essential for writing non-blocking code and avoiding subtle bugs.

DodaTech relies on this understanding for processing thousands of concurrent scan results, handling Websocket connections, and building responsive UIs.

What You'll Learn

  • The call stack and stack frames
  • Task queue (macrotasks) vs microtask queue
  • How setTimeout, Promises, and async/await interact
  • requestAnimationFrame and the render pipeline
  • Common event loop pitfalls
  • Measuring and debugging async execution

The Call Stack

function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function main() {
  console.log(square(5)); // 25
}
main();

// Stack trace:
// 1. main() pushed
// 2. square(5) pushed
// 3. multiply(5, 5) pushed
// 4. multiply returns → popped
// 5. square returns → popped
// 6. main returns → popped

Task Queue vs Microtask Queue

console.log("1: synchronous");

setTimeout(() => console.log("2: macrotask"), 0);

Promise.resolve().then(() => console.log("3: microtask"));

console.log("4: synchronous");

// Output order:
// 1: synchronous
// 4: synchronous
// 3: microtask     ← microtasks run BEFORE next macrotask
// 2: macrotask

// Why? The event loop processes:
// 1. One macrotask (from the main script)
// 2. ALL available microtasks
// 3. Render (if needed)
// 4. Next macrotask

Detailed Execution Order

setTimeout(() => console.log("timeout 1"), 0);
setTimeout(() => console.log("timeout 2"), 0);

Promise.resolve()
  .then(() => {
    console.log("promise 1");
    // Schedule another microtask
    return Promise.resolve();
  })
  .then(() => console.log("promise 2"));

queueMicrotask(() => console.log("microtask via queueMicrotask"));

// Output:
// promise 1
// microtask via queueMicrotask
// promise 2
// timeout 1
// timeout 2

// All microtasks (promise 1, microtask, promise 2) run
// before the next macrotask (timeout 1).

Macrotask Sources

// Sources of macrotasks:
// - setTimeout / setInterval
// - setImmediate (Node.js)
// - I/O callbacks (Node.js)
// - UI events (click, keydown, etc.)
// - Message events (postMessage, WebSocket messages)

// setTimeout with 0ms delay:
let start = Date.now();
setTimeout(() => {
  console.log(`Ran after ${Date.now() - start}ms`);
}, 0);
console.log("This runs first");

Microtask Sources

// Sources of microtasks:
// - Promise.then / .catch / .finally
// - async/await (syntactic sugar over Promises)
// - queueMicrotask()
// - MutationObserver
// - process.nextTick (Node.js, runs before other microtasks)

// Warning: microtasks can starve the event loop!
function starveEventLoop() {
  function loop() {
    queueMicrotask(loop);
  }
  loop();
  // This NEVER yields to macrotasks!
  // setTimeout(() => console.log("never runs"), 0);
}

async/await and the Event Loop

async function demo() {
  console.log("A: inside async function (synchronous start)");

  await Promise.resolve();

  console.log("B: after first await (microtask)");

  await Promise.resolve();

  console.log("C: after second await (microtask)");
}

console.log("1: before async call");
demo();
console.log("2: after async call");

// Output:
// 1: before async call
// A: inside async function
// 2: after async call
// B: after first await
// C: after second await

requestAnimationFrame

// rAF runs BEFORE the next paint/render
// but AFTER all microtasks in the current cycle

requestAnimationFrame(() => console.log("rAF 1"));
requestAnimationFrame(() => console.log("rAF 2"));

Promise.resolve().then(() => console.log("microtask"));

// Output:
// microtask
// rAF 1
// rAF 2

// Event loop in browser:
// 1. Macrotask (e.g., click handler)
// 2. All microtasks
// 3. requestAnimationFrame callbacks
// 4. Style calculation + Layout + Paint
// 5. Next macrotask

Common Pitfalls

Pitfall 1: forEach with async

// WRONG: forEach doesn't await
async function processItems(items) {
  items.forEach(async (item) => {
    await fetch(`/api/${item}`);
    // This runs after function returns!
  });
  console.log("Done?"); // Runs before any fetch completes
}

// RIGHT: for...of with await
async function processItemsCorrect(items) {
  for (const item of items) {
    await fetch(`/api/${item}`);
  }
  console.log("Actually done");
}

// RIGHT: Promise.all for parallel
async function processItemsParallel(items) {
  await Promise.all(items.map(item => fetch(`/api/${item}`)));
  console.log("All done (parallel)");
}

Pitfall 2: Microtask Starvation

async function processStream(stream) {
  for await (const chunk of stream) {
    // Processing chunk...
    await 0; // VERY BAD: creates infinite microtasks
  }
}

// Better: use actual async I/O
async function processStreamBetter(stream) {
  for await (const chunk of stream) {
    await processChunk(chunk);
    // Each iteration yields properly
  }
}

Pitfall 3: Timing Assumptions

// Don't assume setTimeout(fn, 0) runs in 0ms
const start = Date.now();
setTimeout(() => {
  console.log(`Delta: ${Date.now() - start}ms`); // Usually 1-10ms+
}, 0);

// Browser minimum: 4ms (clamped)
// Node.js minimum: 1ms (after clamping is applied)

Debugging the Event Loop

// Monitor event loop lag (Node.js)
function monitorLag() {
  const start = Date.now();
  setImmediate(() => {
    const lag = Date.now() - start;
    if (lag > 50) {
      console.warn(`Event loop lag: ${lag}ms`);
    }
  });
}

// Using performance.now() for high-res timing
function measureExecution(fn) {
  const start = performance.now();
  fn();
  return performance.now() - start;
}

// Event loop visualization helper
function logTiming(label, color = "") {
  console.log(`${label} @ ${performance.now().toFixed(2)}ms`);
}

Practice Questions

  1. Predict the output of nested setTimeout and Promise chains.

  2. Write a function that yields control to the event loop without using setTimeout.

  3. Implement a non-blocking version of Array.forEach that processes items in batches (chunks), yielding to the event loop between batches.

  4. Write a debounce function that respects the microtask/macrotask distinction.

  5. Measure event loop lag in the browser using requestAnimationFrame timestamps.

Challenge: Cooperative Multitasking Scheduler

Build a scheduler that runs multiple "tasks" (generators) cooperatively:

class Scheduler {
  tasks = new Map();
  add(name, genFn) { ... }
  run() { ... }
}

const sched = new Scheduler();
sched.add("task1", function*() {
  console.log("A1"); yield;
  console.log("A2"); yield;
  console.log("A3");
});
sched.add("task2", function*() {
  console.log("B1"); yield;
  console.log("B2"); yield;
});
sched.run();
// A1, B1, A2, B2, A3 (interleaved)

This is the same pattern DodaTech uses for managing concurrent scan operations while keeping the UI thread responsive.

Real-World Task: Progressive Data Loader

Write a class that loads data in chunks while keeping the UI responsive:

  • Load next chunk via requestIdleCallback or chunked microtasks
  • Yield to the event loop after each chunk
  • Emit progress events
  • Support cancellation
  • Handle errors without blocking

This powers DodaTech's dashboard where large datasets are loaded incrementally without freezing the browser — essential for displaying millions of scan results across tens of thousands of assets.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro