Skip to content

Latest commit

 

History

History
530 lines (385 loc) · 12.3 KB

File metadata and controls

530 lines (385 loc) · 12.3 KB

Async Nature of JavaScript

Overview

JavaScript is single-threaded, meaning it can only execute one piece of code at a time. Yet it handles asynchronous operations like network requests, timers, and user events without freezing the browser or server. How is this possible? The answer lies in JavaScript's event-driven, non-blocking I/O model powered by the Event Loop. Understanding this mechanism is crucial for writing efficient async code and debugging timing issues.


Synchronous vs Asynchronous

Synchronous Code

Code executes line by line, one at a time. Each operation must complete before the next begins.

console.log("Step 1");
console.log("Step 2");
console.log("Step 3");

// Output:
// Step 1
// Step 2
// Step 3

Problem with synchronous code:

console.log("Fetching data...");

// This blocks everything for 5 seconds!
const start = Date.now();
while (Date.now() - start < 5000) {
  // Blocking the thread
}

console.log("Done!"); // Only prints after 5 seconds

Asynchronous Code

Operations can be initiated and resumed later without blocking the main thread.

console.log("Step 1");

setTimeout(() => {
  console.log("Step 2 (after 2 seconds)");
}, 2000);

console.log("Step 3");

// Output:
// Step 1
// Step 3
// Step 2 (after 2 seconds)

Step 2 doesn't block Steps 1 and 3. The callback is scheduled to run later.


Why is JavaScript Single-Threaded?

JavaScript was designed for browsers. Multiple threads manipulating the same DOM simultaneously would create race conditions and make web development incredibly complex. Instead, JavaScript uses a single thread with an event loop to handle concurrency elegantly.

Note: While JavaScript itself is single-threaded, the runtime environment (browser or Node.js) uses multiple threads behind the scenes for I/O operations, timers, and other tasks.


The JavaScript Runtime

To understand async JavaScript, you need to know about these components:

1. Call Stack

A data structure that tracks function execution. Last In, First Out (LIFO).

function first() {
  second();
  console.log("First done");
}

function second() {
  third();
  console.log("Second done");
}

function third() {
  console.log("Third done");
}

first();

Call Stack Flow:

1. [main]
2. [first] [main]
3. [second] [first] [main]
4. [third] [second] [first] [main]
5. [second] [first] [main]  ← third() completes
6. [first] [main]            ← second() completes
7. [main]                    ← first() completes

2. Web APIs / Node APIs

Browser-provided APIs that operate outside the JavaScript engine:

  • setTimeout, setInterval
  • fetch / XMLHttpRequest
  • DOM events (addEventListener)
  • console.log

These APIs handle operations in separate threads and callback into JavaScript when done.

3. Callback Queue (Task Queue)

A queue of callback functions waiting to be executed. FIFO (First In, First Out).

4. Microtask Queue

A higher-priority queue for Promises and queueMicrotask callbacks.

5. The Event Loop

The orchestrator that continuously checks:

  1. Is the Call Stack empty?
  2. If yes, move the oldest task from the Microtask Queue to the Call Stack
  3. If Microtask Queue is empty, move the oldest task from the Callback Queue to the Call Stack
  4. Repeat

How the Event Loop Works

console.log("Script start");

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

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

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

console.log("Script end");

Execution Flow:

Step Call Stack Web APIs Callback Queue Microtask Queue Console Output
1 console.log("Script start") - - - "Script start"
2 setTimeout(cb, 0) Timer set - - -
3 Promise.resolve().then(cb1) - - Promise 1 -
4 Promise.resolve().then(cb2) - - Promise 1, Promise 2 -
5 console.log("Script end") - - Promise 1, Promise 2 "Script end"
6 - Timer done → cb setTimeout Promise 1, Promise 2 -
7 Promise 1 callback - setTimeout Promise 2 "Promise 1"
8 Promise 2 callback - setTimeout - "Promise 2"
9 setTimeout callback - - - "setTimeout"

Final Output:

Script start
Script end
Promise 1
Promise 2
setTimeout

Key Rule: Microtasks (Promises) always execute before macrotasks (setTimeout, setInterval, I/O callbacks).


Visualizing the Event Loop

┌─────────────────────────────────────────┐
│           JavaScript Engine             │
│  ┌─────────────┐    ┌───────────────┐   │
│  │  Call Stack │    │  Heap (Memory)│   │
│  │             │    │               │   │
│  │  function() │    │  Objects      │   │
│  │  function() │    │  Closures     │   │
│  └─────────────┘    └───────────────┘   │
└─────────────────────────────────────────┘
                    ↑
                    │ checks if empty
              ┌─────┴─────┐
              │ Event Loop│
              └─────┬─────┘
                    │
    ┌───────────────┼───────────────┐
    ↓               ↓               ↓
┌──────────┐  ┌──────────┐  ┌──────────────┐
│Microtask │  │ Callback │  │   Web APIs   │
│  Queue   │  │  Queue   │  │              │
│Promises  │  │setTimeout│  │setTimeout    │
│queueMicro│  │DOM events│  │fetch         │
│task      │  │I/O       │  │DOM events    │
└──────────┘  └──────────┘  └──────────────┘

Microtasks vs Macrotasks

Macrotasks (Task Queue)

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

Microtasks

  • Promise.then/catch/finally
  • queueMicrotask()
  • MutationObserver

Priority Order

console.log("1");

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

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

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

setTimeout(() => console.log("6"), 0);

console.log("7");

// Output:
// 1
// 7
// 3
// 4
// 5
// 2
// 6

Execution Order:

  1. All synchronous code
  2. All microtasks (and microtasks created during microtask execution)
  3. One macrotask
  4. Back to step 2

setTimeout(fn, 0) Explained

console.log("A");

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

console.log("C");

// Output:
// A
// C
// B

Even with a delay of 0ms, the callback goes to the Callback Queue and waits for:

  1. The Call Stack to clear
  2. All microtasks to complete

Note: The actual minimum delay is typically 4ms in browsers (HTML5 spec), even if you specify 0.


Blocking the Event Loop

Synchronous Blocking

console.log("Start");

// This blocks everything!
const start = Date.now();
while (Date.now() - start < 3000) {
  // CPU-intensive work
}

console.log("End"); // 3 seconds later

// Any pending async callbacks are delayed by 3 seconds
setTimeout(() => console.log("Timeout"), 0);

Solutions for Heavy Computation

1. Break into chunks:

function processLargeArray(arr) {
  const chunkSize = 1000;
  let index = 0;

  function processChunk() {
    const chunk = arr.slice(index, index + chunkSize);
    // Process chunk...
    index += chunkSize;

    if (index < arr.length) {
      setTimeout(processChunk, 0); // Yield to event loop
    }
  }

  processChunk();
}

2. Web Workers (Browser):

const worker = new Worker("worker.js");
worker.postMessage({ data: largeArray });
worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

3. Worker Threads (Node.js):

const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");

Common Patterns

Deferring Execution

// Run after current stack clears
setTimeout(() => {
  console.log("Deferred");
}, 0);

// Or using Promises (microtask, runs sooner)
Promise.resolve().then(() => {
  console.log("Microtask deferred");
});

Yielding to the Browser

async function processItems(items) {
  for (const item of items) {
    await processItem(item);
    // Allows browser to render between iterations
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

Common Mistakes

Mistake 1: Expecting Immediate Async Execution

let data;

fetch("/api/data")
  .then(res => res.json())
  .then(json => {
    data = json;
  });

console.log(data); // undefined! Fetch hasn't completed yet.

Mistake 2: Infinite Microtask Loop

function loop() {
  Promise.resolve().then(loop);
}
loop();
// This starves the macrotask queue! setTimeout callbacks never run.

Mistake 3: Confusing setTimeout Order

setTimeout(() => console.log("1"), 100);
setTimeout(() => console.log("2"), 50);
setTimeout(() => console.log("3"), 0);

// Output: 3, 2, 1 (not 1, 2, 3!)

Mistake 4: Race Conditions

let count = 0;

function increment() {
  const current = count;
  setTimeout(() => {
    count = current + 1;
  }, 0);
}

increment();
increment();
// count might not be 2 due to the read-modify-write race!

Practice Exercises

Exercise 1: Predict the Output

console.log("1");

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

Promise.resolve().then(() => {
  console.log("3");
  setTimeout(() => console.log("4"), 0);
});

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

console.log("6");

What is the exact output order?

Exercise 2: Event Loop Visualization

Trace this code through the Call Stack, Web APIs, and queues:

console.log("Start");

setTimeout(() => {
  console.log("Timeout 1");
  Promise.resolve().then(() => console.log("Promise inside timeout"));
}, 0);

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

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

console.log("End");

Exercise 3: Create a Non-blocking Delay

Write a function that pauses for a given time without blocking the event loop.

async function delay(ms) {
  // Your code
}

async function main() {
  console.log("Start");
  await delay(1000);
  console.log("After 1 second");
}

Exercise 4: Task Scheduler

Implement a scheduler that ensures tasks don't block the main thread for more than a given time slice.

class TaskScheduler {
  // Implement runTask(task, timeSlice)
  // Breaks task into chunks if it exceeds timeSlice
}

Exercise 5: Promise vs setTimeout Order

Explain why this outputs in this specific order and how changing the delay affects it:

setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));

Summary

  • JavaScript is single-threaded but handles async operations via the Event Loop
  • The Call Stack tracks synchronous execution (LIFO)
  • Web APIs handle async operations outside the main thread
  • The Callback Queue holds macrotasks (setTimeout, I/O)
  • The Microtask Queue holds Promise callbacks (higher priority)
  • The Event Loop moves tasks from queues to the Call Stack when it's empty
  • Microtasks always run before macrotasks
  • setTimeout(fn, 0) doesn't run immediately — it goes to the Callback Queue
  • Never block the Event Loop with long-running synchronous operations

Next Steps

Now that you understand the async foundation:

  • Callbacks — the original async pattern
  • Promises — cleaner async handling
  • Async/Await — synchronous-looking async code

Happy coding! 🚀