React|Essential of Language and Web

Asynchronous JavaScript and the Event Loop: How Do Promises Work?

4
Asynchronous JavaScript and the Event Loop: How Do Promises Work?

JavaScript is a Single Threaded language. This means it can only process one task at a time. However, the websites we use don't behave that way. We can scroll down a page while data is being fetched, or click a button while an image is loading.

If it can only do one thing at a time, how does it look like everything is happening simultaneously?

The secret lies in the Event Loop, which works hard behind the scenes of the JavaScript engine. Today, we will dig into the principles of asynchronous processing in JavaScript—a concept every React developer must understand.

The Dilemma of the Single Thread

Let's assume Chris is building a dashboard for an e-commerce site. He wrote code to fetch the order list from the server.

// dashboard.js
const orders = fetchOrdersSync(); // Takes 5 seconds
render(orders);
showFooter();

If JavaScript processed everything synchronously, what would happen? For the 5 seconds until fetchOrdersSync finishes, the browser would enter a "frozen" state. No clicking, no scrolling. Users would see this unresponsive screen and leave immediately.

To solve this problem, JavaScript adopted an Asynchronous approach. It effectively says, "I'll leave this long task here for now," and proceeds to execute the next line of code.

The Event Loop and Friends

To understand this asynchronous processing, we need to know the structure of how the JavaScript engine runs. Broadly, three elements cooperate:

  • Call Stack: This is where JavaScript piles up the work it needs to do. Code entering here is executed immediately. Since it is single-threaded, there is exactly one stack.
  • Web APIs: APIs provided by the browser. setTimeout, fetch, and DOM Events belong here. Time-consuming tasks are handled here.
  • Task Queue: A waiting room where tasks that have finished processing in the Web APIs wait to get into the Call Stack.
  • And the traffic controller that coordinates these elements is the Event Loop.

  • Role of the Event Loop: It constantly checks if the Call Stack is empty. If it is empty, it takes a pending task from the Task Queue and moves it to the Call Stack.
  • Practical Mechanism: Promise vs setTimeout

    Let's verify this with code. In what order will the following code be output?

    // async-quiz.js
    console.log('1. Start');
    
    setTimeout(() => {
      console.log('2. Timeout');
    }, 0);
    
    Promise.resolve().then(() => {
      console.log('3. Promise');
    });
    
    console.log('4. End');

    Intuitively, since setTimeout is 0ms, it feels like it should run immediately, and the Promise resolves immediately too, making the order confusing.

    The result is as follows:

    1. Start
    4. End
    3. Promise
    2. Timeout

    Why is the Promise executed before the setTimeout?

    Here, a crucial concept appears. There isn't just one Task Queue.

    The Microtask Queue's Priority (Queue Jumping)

    JavaScript has two types of queues with different priorities.

  • Macro Task Queue (General Task Queue): Handles setTimeout, setInterval, etc.
  • Microtask Queue: Handles Promise, MutationObserver, etc.
  • The rule is simple. When the Call Stack is empty, the Event Loop checks the Microtask Queue first. It processes all tasks in this queue until it is empty before it fetches even a single task from the Macro Task Queue.

    In other words, Promise has a higher priority than setTimeout. It's essentially a VIP line.

    Application in React

    How does this principle apply to React development?

    1. Batching of State Updates

    React 18's Automatic Batching and asynchronous state updates operate similarly to the concept of microtasks, reducing unnecessary re-renders.

    2. Avoiding Heavy Tasks

    It is important not to block the Event Loop.

    Bad Example:

    // Occupies the Call Stack and freezes the browser
    const handleClick = () => {
      // Heavy calculation repeating 1 billion times
      heavyCalculation(); 
      setCount(c => c + 1);
    };

    ✅ Good Example:

    If a heavy calculation is necessary, use a Web Worker, or break the task into smaller chunks using setTimeout to give the Event Loop a chance to breathe.

    Key Takeaways

    The core of JavaScript asynchronous processing is "Not Waiting" and "Priority."

  • Single Thread: JavaScript processes only one task at a time in the Call Stack.
  • Event Loop: When the Call Stack is empty, it fetches tasks from the queue and executes them.
  • Priority: Promise (Microtask) is always executed before setTimeout (Macro Task).
  • Now, when writing asynchronous code, try to imagine which queue your code is lining up in. You will start to see the order of execution.

    🔗 References

  • EventLoop
  • Promise
  • setTimeout

  • Next Post Teaser:

    Now that we've conquered types and async, it's time to dive into the core engine of React. We use useState all the time. It seems like it simply stores values, but hidden inside is the age-old magic of JavaScript called Closures.

    Continuing in: "How useState Remembers Values: Mastering Closures and Execution Context"