Synchronous vs Asynchronous

In synchronous code, each statement runs to completion before the next one starts. That makes the order easy to follow. It becomes a problem when a task takes a long time, because everything else has to wait.

console.log("First");
console.log("Second");
console.log("Third");
// "First"
// "Second"
// "Third"

How Blocking Code Delays Execution

A blocking operation prevents further execution until it completes. If one task takes a long time, everything behind it is delayed:

function heavyComputation() {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  }
  return sum;
}

console.log("Start");
heavyComputation();  // Blocks for several seconds
console.log("Done");  // Only runs after the loop finishes

In a browser, this would freeze the UI until heavyComputation finishes. While the loop runs, the user cannot scroll, cannot click, and animations do not play.

Non-Blocking Code with setTimeout

The setTimeout function schedules a callback to run after a minimum delay (in milliseconds). It does not block execution:

console.log("Start");

setTimeout(() => {
  console.log("Delayed");  // Runs after at least 2000ms
}, 2000);

console.log("End");
// "Start"
// "End"
// "Delayed"  (after ~2 seconds)

The callback is handed off to the runtime environment and placed in the callback queue when the timer expires. It only runs once the call stack is empty.

Even with a delay of 0, the callback does not run immediately. It still goes through the queue:

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
// "A"
// "C"
// "B"

Repeating with setInterval

setInterval repeatedly calls a function at a fixed interval:

let count = 0;
const intervalId = setInterval(() => {
  count++;
  console.log(`Tick ${count}`);
  if (count === 3) {
    clearInterval(intervalId);  // Stop after 3 ticks
  }
}, 1000);
// "Tick 1"  (after ~1s)
// "Tick 2"  (after ~2s)
// "Tick 3"  (after ~3s)

Use clearInterval to stop the repeating execution and clearTimeout to cancel a pending setTimeout.

The Event Loop

JavaScript uses an event loop to manage async operations. The key components are:

  1. Call Stack: Where the engine executes function calls, one at a time (LIFO)
  2. Web/Node APIs: The runtime environment handles timers, network requests, and other async operations outside the call stack
  3. Callback Queue (Task Queue): Completed async callbacks wait here
  4. Microtask Queue: Holds Promise callbacks (.then, .catch), which have higher priority than the callback queue
  5. Event Loop: Continuously checks if the call stack is empty. If so, it moves the next task from the microtask queue (first) or callback queue into the call stack
  Call Stack          Web APIs
  ┌──────────┐       ┌──────────────┐
  │ main()   │──────>│ setTimeout() │
  └──────────┘       │ fetch()      │
       ^             │ DOM events   │
       │             └──────┬───────┘
       │                    │
  Event Loop                v
       │             ┌──────────────┐
       └─────────────│ Task Queue   │
                     └──────────────┘

Here is how the event loop processes a simple example:

console.log("1");

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

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");
// "1"
// "4"
// "3"  (microtask — runs before callback queue)
// "2"  (callback queue — runs after microtasks)

Keep in mind that the microtask queue (Promises) always drains before the callback queue (setTimeout), even if the timer has already expired.

Callbacks, Promises, and async/await are all patterns for working with async operations. They differ in how you organize the code that runs when an async task completes.