MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Understanding the JavaScript Event Loop

A comprehensive guide to the JavaScript event loop, call stack, task queue, and microtask queue.

JavaScriptEvent LoopAsync

By MinhVo

Introduction

The JavaScript event loop is the mechanism that enables single-threaded JavaScript to handle asynchronous operations without blocking. Every setTimeout, every fetch call, every DOM event — they all flow through the event loop. Yet despite its fundamental importance, the event loop remains one of the most misunderstood concepts in JavaScript development.

Misunderstanding the event loop leads to subtle bugs: race conditions in async code, UI freezes from blocking operations, and unexpected execution order in Promise-heavy codebases. This guide demystifies the event loop from the engine level up, giving you the mental model senior developers use to reason about asynchronous JavaScript behavior.

JavaScript Event Loop

Understanding the Event Loop: Core Concepts

JavaScript Is Single-Threaded

JavaScript executes code on a single thread — it can only do one thing at a time. This simplifies programming (no mutexes, no deadlocks) but creates a challenge: how does it handle operations that take time (network requests, file I/O, timers) without freezing?

The answer is the event loop combined with the browser (or Node.js) runtime APIs. JavaScript offloads slow operations to the runtime, continues executing other code, and picks up the results when they're ready.

The Call Stack

The call stack is a LIFO (Last In, First Out) data structure that tracks function execution. When you call a function, it's pushed onto the stack. When it returns, it's popped off.

function third() {
  console.log('third');
}
 
function second() {
  third();
  console.log('second');
}
 
function first() {
  second();
  console.log('first');
}
 
first();
 
// Call stack progression:
// [first]
// [first, second]
// [first, second, third]  → prints 'third'
// [first, second]         → prints 'second'
// [first]                 → prints 'first'
// []                      → empty

If the call stack isn't empty, no other code can run. This is why a long-running synchronous operation blocks everything — the event loop can't process events until the stack empties.

The Task Queue (Macrotask Queue)

When an asynchronous operation completes (timer fires, network response arrives, DOM event occurs), its callback is placed in the task queue (also called the macrotask queue). The event loop checks if the call stack is empty; if so, it dequeues the oldest task and pushes its callback onto the stack.

console.log('Start');
 
setTimeout(() => {
  console.log('Timeout callback');
}, 0);
 
console.log('End');
 
// Execution order:
// 1. 'Start' (synchronous)
// 2. 'End' (synchronous)
// 3. 'Timeout callback' (macrotask, runs after stack empties)

The setTimeout(fn, 0) doesn't execute immediately — it schedules fn as a macrotask that runs after the current synchronous code and all microtasks complete.

The Microtask Queue

The microtask queue has higher priority than the macrotask queue. After each macrotask completes (and between each task), the event loop drains all microtasks before moving to the next macrotask.

Promise callbacks (.then(), .catch(), .finally()) and queueMicrotask() callbacks are microtasks. MutationObserver callbacks are also microtasks in browsers.

console.log('1: Script start');
 
setTimeout(() => {
  console.log('2: setTimeout');
}, 0);
 
Promise.resolve().then(() => {
  console.log('3: Promise');
});
 
console.log('4: Script end');
 
// Output:
// 1: Script start
// 4: Script end
// 3: Promise        ← microtask runs before macrotask
// 2: setTimeout      ← macrotask runs after all microtasks

Event Loop Architecture

Architecture and Design Patterns

How the Event Loop Works (Step by Step)

The event loop algorithm, simplified from the HTML specification:

while (true) {
  // 1. Execute the oldest macrotask from the task queue
  let task = taskQueue.dequeue();
  if (task) {
    execute(task);
  }

  // 2. Drain ALL microtasks
  while (microtaskQueue.length > 0) {
    let microtask = microtaskQueue.dequeue();
    execute(microtask);
  }

  // 3. Render (browser only) — update the DOM, paint
  if (isBrowser) {
    render();
  }

  // 4. If idle, wait for new tasks
  waitForNewTasks();
}

Key insight: the microtask queue is fully drained between every macrotask. This means if microtasks keep adding more microtasks, the event loop never reaches the next macrotask (or render), causing the page to freeze.

Microtask vs Macrotask Ordering

Understanding the exact execution order requires seeing how multiple async operations interact:

console.log('1: Start');
 
setTimeout(() => console.log('2: setTimeout 1'), 0);
 
Promise.resolve()
  .then(() => {
    console.log('3: Promise 1');
    return Promise.resolve();
  })
  .then(() => {
    console.log('4: Promise 2');
  });
 
setTimeout(() => console.log('5: setTimeout 2'), 0);
 
queueMicrotask(() => console.log('6: microtask'));
 
console.log('7: End');
 
// Output:
// 1: Start
// 7: End
// 3: Promise 1
// 6: microtask
// 4: Promise 2
// 2: setTimeout 1
// 5: setTimeout 2

The microtask queue is drained completely before any macrotask runs. Within the microtask queue, callbacks execute in FIFO order.

requestAnimationFrame Timing

requestAnimationFrame (rAF) runs before the browser repaints, after microtasks are drained but before the next macrotask.

Macrotask → Microtasks → requestAnimationFrame → Repaint → Next Macrotask
function animate() {
  // This runs before the browser paints
  element.style.left = `${position}px`;
  position += 1;
 
  if (position < 300) {
    requestAnimationFrame(animate);
  }
}
 
requestAnimationFrame(animate);

Step-by-Step Implementation

The Blocking Problem

A synchronous operation that takes too long blocks the event loop, preventing all async callbacks from executing:

// BAD: Blocks the event loop for the entire duration
function heavyComputation(n: number): number {
  let result = 0;
  for (let i = 0; i < n; i++) {
    result += Math.sqrt(i) * Math.sin(i);
  }
  return result;
}
 
// The page freezes during this computation
setTimeout(() => console.log('This is delayed'), 0);
heavyComputation(100_000_000); // Takes seconds
console.log('Done'); // This runs immediately after computation
// The setTimeout callback is delayed until heavyComputation finishes

Breaking Work Into Chunks

To keep the event loop responsive, break long-running work into smaller chunks using setTimeout or requestIdleCallback:

function processInChunks<T>(
  items: T[],
  processor: (item: T) => void,
  chunkSize: number = 1000
): Promise<void> {
  return new Promise((resolve) => {
    let index = 0;
 
    function processChunk() {
      const end = Math.min(index + chunkSize, items.length);
 
      for (; index < end; index++) {
        processor(items[index]);
      }
 
      if (index < items.length) {
        // Yield to the event loop before processing next chunk
        setTimeout(processChunk, 0);
      } else {
        resolve();
      }
    }
 
    processChunk();
  });
}
 
// Usage: process 1 million items without blocking
await processInChunks(largeArray, processItem, 5000);
// UI remains responsive between chunks

Task Scheduling with queueMicrotask

Use queueMicrotask for operations that need to run after the current synchronous code but before any macrotasks:

class BatchProcessor {
  private pending: Array<() => void> = [];
  private scheduled = false;
 
  add(task: () => void) {
    this.pending.push(task);
 
    if (!this.scheduled) {
      this.scheduled = true;
      queueMicrotask(() => this.flush());
    }
  }
 
  private flush() {
    const tasks = [...this.pending];
    this.pending = [];
    this.scheduled = false;
 
    for (const task of tasks) {
      task();
    }
  }
}
 
// All tasks added in the same synchronous block
// are batched into a single microtask
const batch = new BatchProcessor();
batch.add(() => console.log('Task 1'));
batch.add(() => console.log('Task 2'));
batch.add(() => console.log('Task 3'));
// All three tasks run together in one microtask

Event Loop Visualization

Real-World Use Cases

Use Case 1: Debouncing User Input

Debouncing uses the event loop's timer mechanism to delay execution until the user stops typing:

function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | null = null;
 
  return (...args: Parameters<T>) => {
    if (timer) clearTimeout(timer);
 
    timer = setTimeout(() => {
      fn(...args);
      timer = null;
    }, delay);
  };
}
 
const search = debounce(async (query: string) => {
  const results = await fetch(`/api/search?q=${query}`);
  renderResults(await results.json());
}, 300);
 
inputElement.addEventListener('input', (e) => {
  search(e.target.value);
});

Use Case 2: Rendering Pipeline Integration

Coordinating DOM reads and writes to avoid layout thrashing:

function updateLayout() {
  // BAD: Interleaved reads and writes cause layout thrashing
  elements.forEach(el => {
    const height = el.offsetHeight;  // Forces layout (read)
    el.style.width = `${height}px`;  // Triggers reflow (write)
  });
 
  // GOOD: Batch reads, then batch writes
  const heights = elements.map(el => el.offsetHeight); // All reads
  elements.forEach((el, i) => {
    el.style.width = `${heights[i]}px`; // All writes
  });
}
 
// BEST: Use requestAnimationFrame for DOM updates
function scheduledUpdate() {
  requestAnimationFrame(() => {
    const heights = elements.map(el => el.offsetHeight);
    elements.forEach((el, i) => {
      el.style.width = `${heights[i]}px`;
    });
  });
}

Use Case 3: Cooperative Scheduling

Using scheduler.yield() (modern browsers) or manual yielding to keep the UI responsive during heavy work:

async function processLargeDataset(items: Item[]) {
  const results: Result[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(await processItem(items[i]));
 
    // Yield every 100 items to keep UI responsive
    if (i % 100 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
      updateProgress(i / items.length);
    }
  }
 
  return results;
}

Use Case 4: Priority Task Scheduling

class PriorityScheduler {
  private queues = {
    high: [] as Array<() => Promise<void>>,
    normal: [] as Array<() => Promise<void>>,
    low: [] as Array<() => Promise<void>>,
  };
 
  schedule(task: () => Promise<void>, priority: 'high' | 'normal' | 'low' = 'normal') {
    this.queues[priority].push(task);
    queueMicrotask(() => this.drain());
  }
 
  private async drain() {
    for (const priority of ['high', 'normal', 'low'] as const) {
      while (this.queues[priority].length > 0) {
        const task = this.queues[priority].shift()!;
        await task();
        // Yield between tasks
        await new Promise(resolve => setTimeout(resolve, 0));
      }
    }
  }
}

Best Practices for Production

  1. Never block the main thread: Long synchronous computations freeze the UI. Break work into chunks or use Web Workers for CPU-intensive tasks.

  2. Understand microtask vs macrotask priority: Promise callbacks (microtasks) run before setTimeout callbacks (macrotasks). Use this knowledge to control execution order.

  3. Use requestAnimationFrame for DOM updates: Batch DOM reads and writes inside rAF callbacks to avoid layout thrashing and ensure smooth animations.

  4. Debounce high-frequency events: Events like scroll, resize, and input fire many times per second. Debounce or throttle handlers to avoid overwhelming the event loop.

  5. Avoid recursive microtasks: A microtask that schedules another microtask can starve macrotasks and prevent rendering, causing the page to appear frozen.

  6. Use setTimeout(fn, 0) to yield: When processing large batches, periodically yield to the event loop with setTimeout(fn, 0) to keep the UI responsive.

  7. Prefer queueMicrotask over Promise.resolve().then(): It's more explicit and avoids creating unnecessary Promise objects.

  8. Test async execution order: Write tests that verify the order of operations in complex async flows to catch subtle timing bugs.

Common Pitfalls and Solutions

PitfallImpactSolution
Blocking the main threadUI freezes, events queue upUse Web Workers or chunked processing
Assuming setTimeout(fn, 0) is immediateUnexpected execution orderIt runs after all microtasks and current code
Recursive microtasksStarves macrotasks and renderingLimit microtask depth or use macrotasks
Interleaved DOM reads/writesLayout thrashing, poor performanceBatch reads, then writes inside rAF
Not draining microtask queue in testsTests pass but production failsUse proper async testing utilities
Confusing setTimeout and setIntervalTimer drift, overlapping callbacksPrefer setTimeout with recursive scheduling

Performance Optimization

Timer Clamping

Browsers clamp setTimeout to a minimum of 4ms after 5 nested calls. This prevents abuse of timers for CPU-intensive work:

// After 5 levels of nesting, minimum delay becomes 4ms
function nestedTimers(count: number) {
  const start = performance.now();
  setTimeout(() => {
    const elapsed = performance.now() - start;
    console.log(`Call ${count}: ${elapsed.toFixed(1)}ms`);
    if (count < 10) nestedTimers(count + 1);
  }, 0);
}
 
// First 5 calls: ~0-1ms
// Calls 6+: ~4ms minimum (browser clamping)

Web Workers for CPU-Intensive Work

Offload heavy computation to a Web Worker to keep the main thread free:

// worker.ts
self.addEventListener('message', (e) => {
  const { data, operation } = e.data;
  const result = heavyComputation(data, operation);
  self.postMessage(result);
});
 
// main.ts
function runInWorker(data: Data): Promise<Result> {
  return new Promise((resolve) => {
    const worker = new Worker('worker.js');
    worker.postMessage({ data, operation: 'process' });
    worker.addEventListener('message', (e) => {
      resolve(e.data);
      worker.terminate();
    });
  });
}

requestIdleCallback for Low-Priority Work

Schedule non-urgent work during idle periods:

function scheduleIdleWork(task: () => void) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback((deadline) => {
      if (deadline.timeRemaining() > 0) {
        task();
      } else {
        scheduleIdleWork(task); // Try again later
      }
    });
  } else {
    setTimeout(task, 0); // Fallback
  }
}
 
// Low-priority analytics, prefetching, etc.
scheduleIdleWork(() => {
  sendAnalyticsData(collectedEvents);
});

Comparison with Alternatives

ApproachConcurrencyUse CaseLimitation
Event LoopCooperativeI/O-bound operationsSingle-threaded, blocks on CPU
Web WorkersTrue parallelismCPU-intensive computationNo DOM access, message passing overhead
setTimeoutMacrotask schedulingYielding to event loop4ms minimum after nesting
queueMicrotaskMicrotask schedulingHigh-priority deferred workCan starve rendering
requestAnimationFrameRender-alignedDOM updates, animationsBrowser-only
requestIdleCallbackIdle schedulingLow-priority background workBrowser-only, not guaranteed

Advanced Patterns

Custom Task Scheduler

class TaskScheduler {
  private taskQueue: Array<{ task: () => Promise<void>; priority: number }> = [];
  private isRunning = false;
 
  schedule(task: () => Promise<void>, priority: number = 0) {
    this.taskQueue.push({ task, priority });
    this.taskQueue.sort((a, b) => b.priority - a.priority);
 
    if (!this.isRunning) {
      this.isRunning = true;
      queueMicrotask(() => this.run());
    }
  }
 
  private async run() {
    while (this.taskQueue.length > 0) {
      const { task } = this.taskQueue.shift()!;
      await task();
 
      // Yield to the event loop between tasks
      await new Promise(resolve => setTimeout(resolve, 0));
    }
    this.isRunning = false;
  }
}

Event Loop Monitoring

function monitorEventLoop(interval: number = 50) {
  let lastCheck = Date.now();
 
  function check() {
    const now = Date.now();
    const delay = now - lastCheck - interval;
 
    if (delay > 100) {
      console.warn(`Event loop blocked for ${delay}ms`);
    }
 
    lastCheck = now;
    setTimeout(check, interval);
  }
 
  setTimeout(check, interval);
}
 
// Start monitoring in development
monitorEventLoop();

Testing Strategies

describe('Event loop behavior', () => {
  it('should execute microtasks before macrotasks', async () => {
    const order: string[] = [];
 
    setTimeout(() => order.push('macrotask'), 0);
    Promise.resolve().then(() => order.push('microtask'));
    queueMicrotask(() => order.push('queueMicrotask'));
 
    // Wait for all tasks to complete
    await new Promise(resolve => setTimeout(resolve, 10));
 
    expect(order).toEqual(['microtask', 'queueMicrotask', 'macrotask']);
  });
 
  it('should drain all microtasks before next macrotask', async () => {
    const order: string[] = [];
 
    setTimeout(() => order.push('macrotask'), 0);
 
    Promise.resolve()
      .then(() => {
        order.push('microtask 1');
        return Promise.resolve();
      })
      .then(() => {
        order.push('microtask 2');
      });
 
    await new Promise(resolve => setTimeout(resolve, 10));
 
    expect(order).toEqual(['microtask 1', 'microtask 2', 'macrotask']);
  });
 
  it('should not block the event loop with chunked processing', async () => {
    const events: string[] = [];
 
    // Schedule a macrotask
    setTimeout(() => events.push('timer'), 0);
 
    // Process items in chunks
    const items = Array.from({ length: 10000 }, (_, i) => i);
    await processInChunks(items, () => {}, 1000);
 
    events.push('processing done');
 
    // Timer should have run during processing
    expect(events).toContain('timer');
  });
});

Future Outlook

The Scheduler API (scheduler.yield(), scheduler.postTask()) is emerging as the modern way to cooperatively schedule work with the browser. It provides priority-based task scheduling that integrates with the event loop more cleanly than raw setTimeout.

Web Locks API and Async Clipboard API are examples of new browser APIs that integrate with the event loop's task queuing mechanism. The trend is toward more granular control over when and how tasks execute.

Node.js is evolving its event loop implementation with better diagnostics (async_hooks, diagnostic_channel) and worker threads for true parallelism. Understanding the event loop remains essential even as these abstractions improve.

Conclusion

The JavaScript event loop is the engine that makes asynchronous programming possible on a single thread. By understanding the call stack, task queue, and microtask queue — and how they interact — you gain the ability to reason about any async behavior in JavaScript.

Key takeaways:

  1. JavaScript is single-threaded — the event loop enables non-blocking async operations
  2. The call stack must be empty before the event loop processes queued tasks
  3. Microtasks (Promises) have priority over macrotasks (setTimeout, setInterval)
  4. All microtasks drain between each macrotask and before rendering
  5. Never block the main thread — use chunking or Web Workers for heavy work
  6. Use requestAnimationFrame for DOM updates and animations
  7. Debounce high-frequency events to avoid overwhelming the event loop

Master the event loop, and you master the runtime behavior of every JavaScript application. Every async pattern, every performance optimization, every debugging session — it all comes back to understanding how the event loop processes tasks.