Understanding Asynchronous JavaScript

Yasar ArafathYasar Arafath
5 min read

JavaScript is a single-threaded, non-blocking, asynchronous language primarily designed to run in web browsers. While its synchronous nature makes it easier to reason about control flow, modern web applications often require asynchronous behavior for tasks such as fetching data from APIs, handling user inputs, or delaying execution. This is where the event loop, microtasks, and macrotasks come into play.

Let’s break down how asynchronous JavaScript works under the hood and clarify the mechanics behind its concurrency model.


The Single-Threaded Nature of JavaScript

JavaScript runs in a single thread, meaning it can only execute one piece of code at a time. Unlike multithreaded languages like Java or C++, where tasks can run in parallel, JavaScript relies on an event loop and a task queue system to handle asynchronous operations without blocking the main thread.

This design choice avoids issues like race conditions and deadlocks, but requires a smart way to manage long-running or delayed tasks.


Synchronous vs Asynchronous Code

Synchronous Code:

console.log('A');
console.log('B');
console.log('C');

Output:

A
B
C

Each line executes one after the other.

Asynchronous Code:

console.log('A');
setTimeout(() => console.log('B'), 1000);
console.log('C');

Output:

A
C
B

Here, setTimeout schedules the callback to run after at least 1000 ms, but doesn’t block execution. This is where the event loop comes into action.


What is the Event Loop?

The event loop is the mechanism that manages the execution of synchronous and asynchronous code in JavaScript. It constantly checks if the call stack is empty, and if so, it pushes the next task from the queue (macro or micro) onto the call stack for execution.

The Event Loop Lifecycle:

  1. Execute all synchronous code in the call stack.

  2. Empty the microtask queue (e.g., Promise callbacks).

  3. Pick a macrotask (e.g., setTimeout, setInterval, I/O events) and move it to the stack.

  4. Repeat.


Call Stack

The call stack is a data structure that stores the functions to be executed. When a function is called, it’s pushed onto the stack. Once it's done executing, it’s popped off.

Example:

function foo() {
  bar();
}

function bar() {
  console.log('Hello');
}

foo();

Stack:

foo() → bar() → console.log('Hello')

Task Queues: Microtasks vs Macrotasks

JavaScript has two types of task queues:

1. Macrotasks Queue (a.k.a. Task Queue)

These include:

  • setTimeout

  • setInterval

  • setImmediate (Node.js)

  • I/O callbacks

  • UI rendering tasks

2. Microtasks Queue

These include:

  • Promise.then / Promise.catch / Promise.finally

  • queueMicrotask

  • MutationObserver

Key Difference:

Microtasks have higher priority. They are executed immediately after the current call stack is empty, before any macrotasks.


Precedence: Microtasks vs Macrotasks

Let’s illustrate with an example:

console.log('Start');

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

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

console.log('End');

Output:

Start
End
Microtask
Macrotask

What happened?

  1. console.log('Start') runs → Start is printed.

  2. setTimeout is scheduled (macrotask).

  3. Promise.then is scheduled (microtask).

  4. console.log('End') runs → End is printed.

  5. The call stack is empty, so event loop executes microtasks:

    • Microtask is printed.
  6. Then it picks up the macrotask from the queue:

    • Macrotask is printed.

Real-World Scenario

Consider this example mixing micro and macrotasks:

console.log('script start');

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

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

console.log('script end');

Expected Output:

script start
script end
promise1
promise2
setTimeout

Explanation:

  1. console.log('script start') and console.log('script end') execute first.

  2. setTimeout(..., 0) is scheduled as a macrotask.

  3. Promises (promise1 and promise2) are microtasks.

  4. After synchronous code ends, the event loop runs all microtasks (promise1 then promise2).

  5. Finally, the macrotask from setTimeout is executed.


Nested Microtasks

Microtasks can queue more microtasks:

Promise.resolve()
  .then(() => {
    console.log('Microtask 1');
    return Promise.resolve();
  })
  .then(() => {
    console.log('Microtask 2');
  });

Each .then() schedules a new microtask. Even within a microtask, new microtasks are added and executed in the same turn of the loop before any macrotask is processed.


Browser Rendering and Task Timing

Microtasks are executed before the browser can render the DOM. This is crucial for performance-sensitive applications.

Excessive or infinite microtasks can block rendering:

function blockRender() {
  Promise.resolve().then(blockRender);
}
blockRender();

This creates a starvation scenario: the browser never gets to render anything.

Use microtasks for quick operations, not infinite or heavy computations.


Node.js and Event Loop

Node.js has a more detailed event loop model with phases:

  1. Timers (setTimeout, setInterval)

  2. Pending callbacks

  3. Idle, prepare

  4. Poll

  5. Check (setImmediate)

  6. Close callbacks

Microtasks (process.nextTick, Promises) are executed between each phase.

Order of priority:

  1. process.nextTick

  2. Promises (.then)

  3. Timer callbacks

  4. setImmediate

Example:

setImmediate(() => console.log('Immediate'));
setTimeout(() => console.log('Timeout'), 0);
process.nextTick(() => console.log('NextTick'));
Promise.resolve().then(() => console.log('Promise'));

Output (usually):

NextTick
Promise
Timeout
Immediate

Best Practices for Async JavaScript

  • Use Promises and async/await for readable asynchronous code.

  • Avoid blocking the main thread with long synchronous operations.

  • Keep microtasks short to avoid delaying UI updates or other tasks.

  • Prefer queueMicrotask() for tasks you want to run immediately after the current operation.


Conclusion

Understanding how JavaScript handles asynchronous tasks via the event loop, microtasks, and macrotasks is critical for writing efficient, non-blocking applications.

Here’s a summary:

  • Synchronous code runs to completion before asynchronous code.

  • Microtasks (Promises, queueMicrotask) run right after the call stack clears.

  • Macrotasks (setTimeout, setInterval) run after all microtasks are processed.

  • The event loop orchestrates the order of execution.

Mastering these concepts enables you to write code that is not only performant but also behaves predictably across different scenarios.

10
Subscribe to my newsletter

Read articles from Yasar Arafath directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yasar Arafath
Yasar Arafath