DEV Community

reactuse.com
reactuse.com

Posted on

Demystifying the JS Event Loop: A ByteDance Interview Question to Deeply Understand async/await, Promise, and RAF

🚀 Explore more React Hooks possibilities? Visit www.reactuse.com for complete documentation and install via npm install @reactuses/core to supercharge your React development efficiency!

Introduction

In front-end development, JavaScript's Event Loop mechanism is crucial for understanding asynchronous programming and optimizing performance. It's not only a frequently asked interview question but also the foundation for writing efficient, non-blocking code. However, many developers only have a superficial understanding of it, especially when modern asynchronous APIs like async/await, Promise, and requestAnimationFrame are intertwined, making their execution order often confusing.

Today, we will delve into the mysteries of the JavaScript Event Loop through a classic ByteDance interview question. This question cleverly combines synchronous code, setTimeout, requestAnimationFrame, async/await, and Promise, aiming to test your comprehensive understanding of the event loop mechanism. Are you ready? Let's uncover this mystery together and make JavaScript's asynchronous execution no longer a puzzle!

Initial Code Exploration

Let's first look at the source code of this interview question:

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  console.log("async2");
}
console.log("script start");
setTimeout(() => {
  console.log("setTimeout");
}, 0);
requestAnimationFrame(() => {
  console.log("requestAnimationFrame");
});
async1();
new Promise(resolve => {
  console.log("promise1");
  resolve();
}).then(() => {
  console.log("promise2");
});
console.log("script end");
Enter fullscreen mode Exit fullscreen mode

Without running the code, can you accurately state the final output order? This is the secret that this article will reveal.

Deep Dive into JavaScript Event Loop Mechanism

To fully understand the execution order of the code above, we first need to have a clear understanding of JavaScript's event loop mechanism. JavaScript is a single-threaded language, which means it can only execute one task at a time. So, how does it handle time-consuming asynchronous operations (like network requests, timers, user interactions) without blocking the main thread? The answer is the event loop.

The core concepts of the event loop include:

1. Synchronous and Asynchronous Tasks

  • Synchronous Tasks: Tasks that are queued and executed on the main thread. Only when the previous task is completed can the next task be executed. They directly enter the JavaScript engine's Call Stack and are executed immediately.
  • Asynchronous Tasks: Tasks that do not enter the main thread directly but instead enter the "Task Queue". Only when all synchronous tasks in the main thread have been executed will the event loop take asynchronous tasks from the task queue and put them into the execution stack for execution.

2. Call Stack

The Call Stack is a Last-In, First-Out (LIFO) data structure used to store function calls. When a function is called, it is pushed onto the stack; when the function finishes execution, it is popped off the stack. The JavaScript engine continuously checks if the Call Stack is empty during code execution. If it is empty, it means all synchronous tasks have been executed.

3. Web APIs

Browsers (or Node.js environments) provide a series of Web APIs for handling asynchronous operations, such as setTimeout, setInterval, XMLHttpRequest, DOM events, etc. When the JavaScript engine encounters these asynchronous APIs, it hands these tasks over to the corresponding Web API module for processing, without blocking the main thread. When the Web API finishes processing, it places the corresponding callback function into the task queue.

4. Task Queues

The task queue is where asynchronous task callback functions are stored. Depending on the type of task, task queues are divided into two types:

  • Macrotask Queue: Also known as the Task Queue or Callback Queue. It mainly includes:

    • setTimeout
    • setInterval
    • I/O operations (e.g., file reading/writing, network requests)
    • UI rendering (browser environment)
    • MessageChannel
    • setImmediate (Node.js)
  • Microtask Queue: Has higher priority than the macrotask queue. It mainly includes:

    • Promise.then(), .catch(), .finally()
    • async/await (essentially syntactic sugar for Promises)
    • MutationObserver
    • process.nextTick (Node.js)

5. Event Loop Mechanism

The event loop is JavaScript's "scheduler" for implementing asynchronous operations. Its workflow can be summarized in the following steps:

  1. Execute Synchronous Code: The JavaScript engine first executes all synchronous tasks in the Call Stack until the Call Stack is empty.
  2. Execute Microtasks: When the Call Stack is empty, the event loop checks the microtask queue. If the microtask queue is not empty, it executes all microtasks at once until the microtask queue is empty. During the execution of microtasks, if new microtasks are generated, these new microtasks are also added to the end of the current microtask queue and executed in the current loop.
  3. Execute Macrotask: When the microtask queue is empty, the event loop takes one macrotask from the macrotask queue (note: only one macrotask is taken per loop) and puts it into the execution stack for execution.
  4. Repeat Loop: Steps 2 and 3 above are repeated continuously, forming a loop, until all tasks are executed.

In short, the priority of the event loop is: Synchronous Tasks > Microtasks > Macrotasks. After each macrotask is executed, the microtask queue is cleared, and then the next macrotask is executed. This is key to understanding the execution order of complex asynchronous code.

In-depth Analysis of the ByteDance Interview Question

Now, let's combine our knowledge of the event loop and analyze the execution order of the code in this interview question step by step. Remember, the event loop in a browser environment includes a rendering phase, and the execution timing of requestAnimationFrame (RAF) is closely related to rendering.

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  console.log("async2");
}
console.log("script start");
setTimeout(() => {
  console.log("setTimeout");
}, 0);
requestAnimationFrame(() => {
  console.log("requestAnimationFrame");
});
async1();
new Promise(resolve => {
  console.log("promise1");
  resolve();
}).then(() => {
  console.log("promise2");
});
console.log("script end");
Enter fullscreen mode Exit fullscreen mode

Detailed Execution Steps:

  1. console.log("script start");

    • This is synchronous code, executed immediately.
    • Output: script start
  2. setTimeout(() => { console.log("setTimeout"); }, 0);

    • setTimeout is a macrotask. It is added to Web APIs, and after 0 milliseconds (or as quickly as possible), its callback function is placed into the macrotask queue.
    • Current State: Macrotask Queue: [setTimeout callback]
  3. requestAnimationFrame(() => { console.log("requestAnimationFrame"); });

    • requestAnimationFrame (RAF) is a special asynchronous task that does not belong to the macrotask or microtask queue. Its callback will be executed before the browser's next repaint.
    • Current State: RAF Queue: [requestAnimationFrame callback]
  4. async1();

    • Call async1 function, enter the function.
    • console.log("async1 start");: Synchronous code, executed immediately.
    • Output: async1 start
    • await async2();:
      • Call async2 function, enter the function.
      • console.log("async2");: Synchronous code, executed immediately.
      • Output: async2
      • async2 function finishes execution and returns a resolved Promise. await pauses the execution of async1 and adds the code after await in async1 (i.e., console.log("async1 end");) as a microtask to the microtask queue.
    • Current State: Microtask Queue: [async1 remaining code]
  5. new Promise(resolve => { console.log("promise1"); resolve(); }).then(() => { console.log("promise2"); });

    • The Promise constructor is executed synchronously.
    • console.log("promise1");: Synchronous code, executed immediately.
    • Output: promise1
    • resolve() is called, and the Promise state becomes resolved. The .then() callback function (i.e., console.log("promise2");) is added to the microtask queue.
    • Current State: Microtask Queue: [async1 remaining code, promise2 callback]
  6. console.log("script end");

    • This is synchronous code, executed immediately.
    • Output: script end

At this point, all synchronous code has finished execution, and the Call Stack is empty. The event loop begins checking the microtask queue.

  1. Execute Microtask Queue

    • The event loop takes the first microtask from the microtask queue: the code after await in async1.
    • console.log("async1 end");: Executed.
    • Output: async1 end
    • The event loop continues to take the next microtask from the microtask queue: the promise2 callback.
    • console.log("promise2");: Executed.
    • Output: promise2
    • The microtask queue is empty.
  2. Browser Rendering Phase (if any)

    • Before executing the next macrotask, the browser may perform a render. At this time, if there are RAF callbacks, they will be executed before rendering.
    • console.log("requestAnimationFrame");: Executed.
    • Output: requestAnimationFrame
    • The RAF queue is empty.
  3. Execute Macrotask Queue

    • The event loop takes the first macrotask from the macrotask queue: the setTimeout callback.
    • console.log("setTimeout");: Executed.
    • Output: setTimeout
    • The macrotask queue is empty.

Final Output Order:

script start
async1 start
async2
promise1
script end
async1 end
promise2
requestAnimationFrame
setTimeout
Enter fullscreen mode Exit fullscreen mode

This order perfectly demonstrates the priority of synchronous code, microtasks executing immediately after the current macrotask, and requestAnimationFrame executing before rendering.

The Uniqueness of requestAnimationFrame: Independent Animation Queue

In the code analysis above, we noticed that the requestAnimationFrame (RAF) callback function executes after all microtasks are completed, but before the setTimeout macrotask. This is not accidental, but rather determined by RAF's unique mechanism.

What is requestAnimationFrame?

requestAnimationFrame is a browser API used to optimize animations and visual updates. It tells the browser that you want to perform an animation and requests the browser to call your specified callback function before the next repaint. Its main advantages are:

  1. Synchronization with Browser Refresh Rate: RAF callbacks execute when the browser is ready to render the next frame, typically 60 times per second (60fps). This means your animations are synchronized with the browser's rendering cycle, avoiding dropped frames or stuttering, and providing a smoother visual experience.
  2. Resource Saving: When the page is inactive (e.g., minimized or switched to a background tab), RAF automatically pauses, thereby saving CPU and battery resources.
  3. Avoiding Layout Jitter: Performing DOM operations within an RAF callback ensures that they are completed before the browser performs layout and painting, thereby reducing unnecessary reflows and repaints and improving performance.

RAF's Queue: Neither Macrotask nor Microtask

This is a common misconception: many people believe that RAF callbacks belong to either the macrotask or microtask queue. However, according to the WHATWG HTML standard, RAF callbacks belong to neither macrotasks nor microtasks. They have their own independent scheduling mechanism, often referred to as the "animation frame callback queue" or "rendering queue."

Execution Timing of RAF Callbacks:

In the browser's event loop, a complete cycle roughly follows these steps:

  1. Execute one macrotask (e.g., take a script task or setTimeout callback from the macrotask queue).
  2. Execute all microtasks (until the microtask queue is empty).
  3. Execute requestAnimationFrame callbacks: Before the browser prepares for the next render, all registered RAF callbacks are executed.
  4. The browser performs rendering (layout, painting).
  5. Repeat the above process, entering the next event loop cycle, and taking the next macrotask from the macrotask queue.

It is precisely because RAF callbacks execute after microtasks, before the next macrotask, and before browser rendering, that they can ensure smooth and efficient animations. It provides developers with an opportunity to perform visual updates at the optimal time.

Differences between RAF and setTimeout:

Feature requestAnimationFrame setTimeout(callback, 0)
Scheduling Time Before the browser's next repaint At least after the specified delay, placed in the macrotask queue, waiting for the main thread to be idle
Frame Rate Sync Yes, synchronized with browser refresh rate, usually 60fps No, may lead to dropped frames or stuttering
Resource Consumption Pauses when page is inactive, saves resources Attempts to execute even when page is inactive, may consume resources
Animation Smoothness Smoother, avoids layout jitter May lead to unsmooth animations, prone to jitter
Queue Type Independent animation frame callback queue Macrotask queue

Understanding the independence of RAF and its execution timing is crucial for writing high-performance web animations and optimizing user experience.

Diagrams of Event Loop and RAF Queue

To more intuitively understand the mechanisms of JavaScript event loop and requestAnimationFrame, we provide the following diagrams:

JavaScript Event Loop Overview

JavaScript Event Loop Diagram

The diagram above illustrates the core components of the JavaScript event loop: the Call Stack, Web APIs, Microtask Queue, Macrotask Queue, and the Event Loop itself. Synchronous code executes in the Call Stack, while asynchronous tasks are processed by Web APIs and then enter the corresponding task queues, eventually being scheduled for execution by the event loop.

Event Loop Including requestAnimationFrame

JavaScript Event Loop with RAF Diagram

This diagram further refines the event loop by incorporating the requestAnimationFrame animation frame callback queue. It clearly depicts the execution order of macrotasks, microtasks, and RAF callbacks within an event loop cycle, as well as the timing of browser rendering. RAF callbacks execute after microtasks and before browser rendering, ensuring the smoothness of animations.

Conclusion and Reflection

Through this in-depth analysis of the ByteDance interview question, we have not only reviewed the core concepts of the JavaScript event loop, including synchronous tasks, asynchronous tasks, the call stack, Web APIs, macrotask queue, and microtask queue, but more importantly, we have understood how async/await and Promise utilize the microtask mechanism to achieve efficient asynchronous flow control, and the unique advantages and execution timing of requestAnimationFrame as an independent animation queue.

Understanding the event loop is an essential path to becoming an excellent front-end developer. It can help you:

  • Predict code execution order: Especially in complex asynchronous scenarios.
  • Optimize performance: Avoid blocking the main thread and improve user experience.
  • Debug asynchronous issues: Locate and resolve bugs caused by asynchronous execution order more quickly.

We hope this article helps you gain a deeper understanding of the JavaScript event loop mechanism and enables you to navigate future interviews and practical development with ease. Practice is the best teacher; we recommend you try different combinations of asynchronous code, observe their output, and deepen your understanding.

References:

Top comments (0)