How JavaScript Promise Works: Synchronizing the Main Thread and Call Stack

JavaScript is a powerful programming language that forms the backbone of web development, enabling interactive and dynamic user experiences. A core aspect of JavaScript, and a topic of significant interest, is how it manages asynchronous operations. Central to this is the concept of Promises and their interaction with the JavaScript runtime environment, specifically the main thread and call stack. Understanding this interaction is crucial for developers aiming to write efficient, non-blocking code. In this detailed blog post, we dive deep into the mechanics of JavaScript Promises, shedding light on their role and behavior within the main thread and call stack.

The Landscape of Asynchronous JavaScript

Before exploring Promises, it’s essential to grasp the single-threaded nature of JavaScript. Unlike languages that utilize multiple threads to perform concurrent tasks, JavaScript relies on a single main thread to execute code, process events, and render UI. This model simplifies programming but poses a challenge: how can JavaScript perform long-running tasks (such as fetching data over a network) without blocking the main thread?

The answer lies in asynchronous programming, with Promises being a cornerstone concept.

Introduction to JavaScript Promises

A Promise in JavaScript represents the eventual completion (or failure) of an asynchronous operation and its resulting value. It allows you to attach callbacks rather than passing them into a function, offering a more manageable approach to handling asynchronous operations and avoiding the notorious callback hell.

A Promise has three states:

  • Pending: The initial state, where the operation has not completed yet.
  • Fulfilled: The operation completed successfully.
  • Rejected: The operation failed.

The Mechanics of Promises with the Main Thread and Call Stack

To understand how Promises work within the confines of JavaScript’s single thread and call stack, let’s break down the process:

1. The Event Loop & Call Stack

JavaScript’s runtime uses a call stack, where function calls are placed and executed in a last-in, first-out manner. However, executing asynchronous tasks directly on the call stack would block subsequent code, leading to a poor user experience. This is where the event loop and accompanying Web APIs come into play.

2. Non-Blocking with Web APIs

When a function requires asynchronous processing (like a network request or a timer), JavaScript’s Web APIs take over, offloading the operation from the call stack. This allows the main thread to continue running other code unblocked.

3. Promise and the Microtask Queue

Once an asynchronous task completes, a Promise either resolves or rejects. But instead of returning control immediately to the main thread, resolved or rejected Promises are sent to a special queue called the Microtask Queue. This queue is prioritized over the Callback Queue (where other asynchronous tasks like event listeners end up after being processed by Web APIs).

4. Event Loop Revisited

The Event Loop continuously monitors the Call Stack and the queues. If the Call Stack is empty, the Event Loop will give priority to the tasks in the Microtask Queue (such as the resolution of Promises) before processing tasks in the Callback Queue. This ensures that Promises are settled promptly, enhancing performance and responsiveness.

Lets try with an example

Callback Queue

When an asynchronous operation completes, and it has a callback function attached to it, this function is placed in the Callback Queue. Here is an example with a simple setTimeout(), which is essentially an asynchronous operation:

console.log('Hello');

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

console.log('How are you?');

In this scenario:

  • ‘Hello’ is logged first;
  • The setTimeout function is called next, but it doesn’t execute immediately. Instead, it schedules the callback function ( () => console.log('World') ) to be pushed to the Callback Queue after 2 seconds;
  • ‘How are you?’ is logged next, because our setTimeout has not completed yet;
  • After 2 seconds, the callback function to log ‘World’ is pushed to the Callback Queue. It’s execution, however, is still contingent on the Call Stack being empty;
  • Once the Call Stack is indeed empty, the Event Loop pushes the callback function from the Callback Queue to the Call Stack, and ‘World’ is logged.

So, the output is:

Hello
How are you?
World

Microtask Queue

The Microtask Queue works somewhat similarly to the Callback Queue, but with a key difference: tasks in the Microtask Queue have higher priority and execute right after the current task completes, and before any other task is run, even if the Call Stack is not empty. Also, any microtasks enqueued while running a microtask, will also be executed in the same cycle.

Promises in JavaScript make use of the Microtask Queue. Let’s see an example:

console.log('Hello');

Promise.resolve().then(() => console.log('World'));

console.log('How are you?');

In this scenario:

  • ‘Hello’ is logged first;
  • The Promise is dealt with next, but the .then() callback function ( () => console.log('World') ) is not called immediately. Instead, it is placed in the microtask queue;
  • ‘How are you?’ is logged next, because the Promise callback hasn’t run yet;
  • After current JavaScript execution finishes, the Event Loop checks the Microtask Queue before picking up new tasks from the Call Stack or Callback Queue. As there is a pending task in the Microtask Queue, it is executed and ‘World’ is logged.

So, the output is:

Hello
How are you?
World

To summarize, both queues are used to handle async operations, but the Microtask Queue has higher priority and is processed immediately after the current task completes and before any other task gets to run – including any other tasks from the Callback Queue, timers, or I/O events.

Now let us see how all things work together,

Let’s design a comprehensive example that includes the Callback Queue, Call Stack, and Microtask Queue, demonstrating how they interact within the JavaScript Event Loop. Consider the example code below:

console.log('Script start');

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

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

console.log('Script end');

Now, let’s step through how this code gets executed:

  1. console.log('Script start'); is pushed to the Call Stack, executed, and logs ‘Script start’. It’s then popped off the Call Stack.
  2. setTimeout(...) is called and registers an anonymous callback function to the Web API provided by the runtime environment. This callback function is not immediately added to the Callback Queue; it must wait at least the specified delay (in this case, 0 milliseconds). Remember, the delay doesn’t guarantee execution at exactly that time but rather schedules the callback to be added to the events loop for execution later.
  3. The Promise.resolve() chain is seen next. Promises use the Microtask Queue for their .then callbacks. So, the first .then() callback is queued in the Microtask Queue to be executed immediately after the current script has finished running, but before the scheduled timeout callback.
  4. console.log('Script end'); is executed, logging ‘Script end’. Then it’s removed from the Call Stack.
  5. With the main script done, the JavaScript engine checks the Microtask Queue before checking the Callback Queue. It finds the Promise’s .then() callback and executes it, logging ‘promise1’. Immediately after, the next .then() in the chain adds another callback to the already-empty Microtask Queue. This newly added microtask is executed, logging ‘promise2’.
  6. Even if the timeout was set to 0ms, the setTimeout callback has to wait for all microtasks to complete, as well as for the Call Stack to be empty before it can execute. Once the Microtask Queue is empty and the engine gets back to the Callback Queue, it finally sees the setTimeout callback ready. Now it executes setTimeout’s callback, logging ‘setTimeout’.

So, the final output is as follows:

Script start
Script end
promise1
promise2
setTimeout

Conclusion

Promises in JavaScript are a vital tool for managing asynchronous operations, providing a sophisticated yet straightforward way to work alongside the main thread and call stack. By leveraging Promises, developers can ensure that their applications remain responsive and performant, even when dealing with complex, long-running tasks. As JavaScript continues to evolve, mastering asynchronous programming and the nuances of its runtime environment will remain an essential skill for any web developer.

Remember, the journey of understanding JavaScript’s concurrency model does not stop here. Continue practicing, experimenting with code examples, and deepening your comprehension of JavaScript’s asynchronous capabilities.

Leave a Reply