Primarily, JavaScript is a single-threaded and synchronous language but there are exceptions to this. Think of creating a timed interval and time out as examples of JavaScript utilising asynchronous code around a single thread.
- Call to multi-threaded functionality
- Engine offloads the call to the parent context (usually a Web API)
- Once the timer has finished, the callback is then placed on the Task Queue
- The Event Loop takes the first callback from the TQ, putting it on the Call Stack to be executed
NOTE: The Event Loop will take the callback from the queue once the Call Stack is empty, meaning long-running synchronous tasks left on the Call Stack will defer the callback execution
Web APIs
I mentioned earlier that when the JavaScript engine running in the browser encounters a call to some multi-threaded functionality it will pass it to a Web API.
These interfaces are implemented by the browser itself within a browser context, if you're running the JavaScript code server-side then Chrome, Safari, FireFox and their implementations don't exist so the runtime uses libraries or implement them themselves.
Blocking vs. Non-blocking
Because JavaScript is single-threaded, if it's execution becomes stuck on a long-running task, all other processes wait which includes any interactivity.
const handleButtonClick() {
veryExpensiveFunction();
}
Once that button is pressed, the call stack execution is stuck on said expensive function and more than likely won't be able to even remove the active state from the button nor perform any other logic.
That is blocking code.
On the other hand, non-blocking code is code that doesn't need to resolve or complete before the proceeding lines can be executed.
console.log("Foo");
setTimeout(() => {
console.log("Boo");
}, 0)
console.log("Bar");
Here, the callback will eventually make it's way back from the Web API after a swift 0 second wait and land on the Task Queue. But, referring back to the above, the Event Loop will not read from the Task Queue until the Call Stack is clear so we see the following:
Foo
Bar
Boo
Behind The Scenes Of A Promise
Setting an immediately returning callback within one of these interval or time out functions is (hopefully) a thing of the past because a promise can fit right in it's place. Similar to the Task Queue, promises reside on another, the Microtask Queue which has priority over the TQ but similarly is only processed by the Event Loop when the Call Stack is empty.
console.log("Foo");
setTimeout(() => {
console.log("Boo");
}, 0);
Promise.resolve("has resolved").then((value) => {
console.log("Far", value);
});
console.log("Bar");
Just like before, there will now be an item on the TQ but the MQ also. Once both logging calls have run and complete, the Event Loop will process all of the items in the MQ until then moving on to the TQ.
Foo
Bar
Far has resolved
Boo
The Await Keyword
Awaiting is the trigger for the promise to be placed on the MQ once some checks have completed, like:
- Execution pause: The asynchronous function pauses on the awaited line (non-blocking)
- Continue: Due to the above being non-blocking, the synchronous code following the asynchronous function runs as normal
- Resolution: Waiting on Resolve or Reject
- Complete: Event loop picks up the task and returns execution (value or thrown error) to the awaited line
async function veryExpensiveFunction() {
console.log("Boo");
try {
const response = await fetch("https://google.com");
console.log(response);
} catch (error) {
/* ... */
}
console.log("Far");
}
console.log("Foo");
veryExpensiveFunction();
console.log("Bar");
Although there's more code involved, the principle remains the same as the previous examples. This time the function is entered and executed up to the awaited statement where execution is returned to the synchronous code.
Foo
Boo
Bar
Response (2.67 KB) /* ... */
Far