Asher Cohen
Back to posts

JavaScript Event Loop and Async Programming

Understand how JavaScript handles asynchronous code with the event loop, call stack, and task queue

Introduction

JavaScript is single-threaded and synchronous by nature, yet it handles asynchronous operations seamlessly. The secret? The event loop. Understanding how the event loop works is essential for writing efficient, bug-free asynchronous code.

The Problem: Single-Threaded JavaScript

JavaScript can only execute one statement at a time. But modern web applications need to handle:

  • API requests
  • File I/O
  • User interactions
  • Timers

If these operations were synchronous, the browser would freeze until they completed.

The Solution: The Event Loop

The event loop allows JavaScript to handle asynchronous operations without blocking. It consists of three main components:

1. Call Stack

The call stack tracks what function is currently running. It's LIFO (Last In, First Out).

function first() {
  console.log(1);
}

function second() {
  console.log(2);
}

function third() {
  console.log(3);
}

first();   // Stack: [first]
second();  // Stack: [second]
third();   // Stack: [third]

// Output: 1, 2, 3

2. Web APIs

Browser-provided APIs handle async operations outside the JavaScript engine:

  • setTimeout / setInterval
  • fetch / XMLHttpRequest
  • DOM events
  • Promises

3. Task Queue (Macrotask Queue)

When async operations complete, their callbacks wait in the task queue.

console.log('Start');

setTimeout(() => {
  console.log('Timeout callback');
}, 0);

console.log('End');

// Output: Start, End, Timeout callback

Why? The callback waits in the queue until the stack is empty.

How the Event Loop Works

  1. Execute all synchronous code (call stack)
  2. Check if stack is empty
  3. If empty, check task queue for pending callbacks
  4. Move first callback from queue to stack
  5. Execute callback
  6. Repeat

Visual Example

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

console.log('3');

// Execution order:
// 1. console.log('1') -> Stack: [log]
// 2. setTimeout() -> Web API starts timer
// 3. console.log('3') -> Stack: [log]
// 4. Stack empty, event loop checks queue
// 5. setTimeout callback moves to stack
// 6. console.log('2') -> Stack: [log]

// Output: 1, 3, 2

Microtasks vs Macrotasks

Macrotasks (Task Queue)

  • setTimeout
  • setInterval
  • I/O operations
  • UI rendering
  • setImmediate (Node.js)

Microtasks (Job Queue)

  • Promise callbacks (.then, .catch, .finally)
  • queueMicrotask()
  • MutationObserver

Priority: Microtasks run before macrotasks.

console.log('1');

setTimeout(() => {
  console.log('timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('promise');
});

console.log('2');

// Output: 1, 2, promise, timeout

Callbacks: The Original Async Pattern

Basic Callback

function fetchData(callback) {
  setTimeout(() => {
    const data = { id: 1, name: 'John' };
    callback(data);
  }, 1000);
}

fetchData((data) => {
  console.log(data);
});

The Pyramid of Doom

Nested callbacks create hard-to-read code:

// ❌ Callback hell
getUser(userId, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      getAuthor(comments[0].authorId, (author) => {
        console.log(author);
      });
    });
  });
});

Flattening Callbacks

// ✅ Better - named functions
function handleAuthor(author) {
  console.log(author);
}

function handleComments(comments) {
  getAuthor(comments[0].authorId, handleAuthor);
}

function handlePosts(posts) {
  getComments(posts[0].id, handleComments);
}

getUser(userId, handlePosts);

Promises: Better Async Handling

Promises provide cleaner syntax and better error handling:

getUser(userId)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => getAuthor(comments[0].authorId))
  .then((author) => console.log(author))
  .catch((error) => console.error(error));

Async/Await: The Modern Approach

Async/await makes async code look synchronous:

async function getAuthorInfo(userId) {
  try {
    const user = await getUser(userId);
    const posts = await getPosts(user.id);
    const comments = await getComments(posts[0].id);
    const author = await getAuthor(comments[0].authorId);
    console.log(author);
  } catch (error) {
    console.error(error);
  }
}

Common Pitfalls

Blocking the Event Loop

// ❌ Blocks the event loop
function heavyComputation() {
  for (let i = 0; i < 1000000000; i++) {
    // Long-running loop
  }
}

// ✅ Break into chunks
function chunkedComputation(data, chunkSize = 1000) {
  let i = 0;
  function processChunk() {
    const end = Math.min(i + chunkSize, data.length);
    for (; i < end; i++) {
      // Process data[i]
    }
    if (i < data.length) {
      setTimeout(processChunk, 0); // Yield to event loop
    }
  }
  processChunk();
}

Forgetting Async Operations Are Non-Blocking

// ❌ Assumes synchronous execution
let data;
fetch('/api/data').then(res => res.json()).then(json => data = json);
console.log(data); // undefined - fetch hasn't completed yet

// ✅ Use async/await
async function loadData() {
  const data = await fetch('/api/data').then(res => res.json());
  console.log(data); // Correct
}

Mixing Microtasks and Macrotasks

// Understand the order
console.log('script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'));

console.log('script end');

// Output: script start, script end, promise 1, promise 2, setTimeout

Best Practices

Keep Callbacks Small

// ✅ Focused callback
button.addEventListener('click', () => {
  handleButtonClick();
});

// ❌ Too much logic
button.addEventListener('click', () => {
  // 50 lines of code
});

Use Async/Await for Complex Flows

// ✅ Clear and readable
async function processOrder(order) {
  const user = await getUser(order.userId);
  const payment = await processPayment(order.total);
  const shipment = await createShipment(order.items);
  return { user, payment, shipment };
}

Handle Errors Properly

// ✅ Always handle errors
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    return await response.json();
  } catch (error) {
    console.error('Fetch failed:', error);
    throw error;
  }
}

Avoid Unnecessary setTimeout

// ❌ Unnecessary delay
setTimeout(() => {
  doSomething();
}, 0);

// ✅ Use queueMicrotask for immediate async
queueMicrotask(() => {
  doSomething();
});

Real-World Examples

Debouncing

function debounce(fn, delay) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  };
}

const search = debounce((query) => {
  fetchResults(query);
}, 300);

Polling

async function poll(fn, interval = 1000) {
  try {
    const result = await fn();
    if (result) return result;
  } catch (error) {
    console.error(error);
  }
  return new Promise((resolve) => {
    setTimeout(() => {
      poll(fn, interval).then(resolve);
    }, interval);
  });
}

Sequential Processing

async function processSequentially(items, processor) {
  const results = [];
  for (const item of items) {
    results.push(await processor(item));
  }
  return results;
}

Conclusion

The event loop is JavaScript's mechanism for handling asynchronous operations without blocking. Understanding the call stack, task queue, and microtask queue helps you write more efficient, predictable code. Use callbacks for simple cases, promises for chaining, and async/await for complex flows.

#javascript #event-loop #async #performance