Asynchronous JavaScript (Part 1)
JavaScript, as a programming language, is known for its simplicity and versatility. However, one of its most powerful and sometimes perplexing features is its ability to handle asynchronous operations. In this blog, we will delve into the world of asynchronous JavaScript, demystifying its concepts and showcasing its capabilities.
How does JS handle Async operations?
JS is a single-threaded language.
JS by default only supports synchronous code execution, meaning it executes code line by line, waiting for each operation to complete before moving on to the next.
NOTE: the above property of synchronous code execution only works for operations natively known to JavaScript.
The above property does not apply to setTimeout and setInterval, lets see this with an example.
console.log("Start");
for (let i = 0; i < 1000000000; i++) {
//some task
}
console.log("Task Done");
console.log("End");
//output
Start
Task Done
End
console.log("Start");
setTimeout(function exec() {
console.log("Task Done");
}, 7000);
console.log("End");
//output
Start
End
Task Done
Given that setTimeout
and setInterval
are not native features of JavaScript, how are they executed? Moreover, if they are not part of JavaScript, how are we still able to use them in our JS code?
We'll understand the later part first.
setTimeout
and setInterval
are part of the Web APIs provided by the runtime environment in which JavaScript runs, such as web browsers or Node.js. These environments extend the core JavaScript functionality with additional features, including the ability to handle asynchronous operations. This means that when you use setTimeout
or setInterval
, you are leveraging the capabilities provided by the runtime environment, not JavaScript itself.
Now we know that runtime also provides functionalities that can be leveraged by JS. But how JS handles them?
When we run this code, we have a call stack, an event queue, and an event loop at play:
function process() {
console.log("Start");
setTimeout(function exec() {
console.log("Executed some task");
}, 3000);
for (let i = 0; i < 10000000000; i++) {
//some task
}
console.log("End");
}
process();
What Happens in This Code:
Call to
process
Function:- The
process
function is called, and "Start" is immediately logged to the console.
- The
Encountering
setTimeout
:When
setTimeout
is encountered, JavaScript delegates this task to the runtime environment (like the browser or Node.js) becausesetTimeout
is not a part of the core JavaScript language.The runtime environment starts the 3-second timer and JavaScript continues to the next line without waiting for the timer.
Executing the
for
Loop:- JavaScript then begins executing the
for
loop, which is computationally intensive and takes approximately 9 seconds to complete.
- JavaScript then begins executing the
Timer Completion:
- After 3 seconds, the timer completes, and the runtime environment places the
exec
function into the event queue since the call stack is still busy with thefor
loop.
- After 3 seconds, the timer completes, and the runtime environment places the
Call Stack and Event Loop:
JavaScript, being single-threaded and synchronous, continues executing the
for
loop without interruption, ignoring the event queue until the call stack is clear.The event loop constantly checks if the call stack is empty. It will only push tasks from the event queue to the call stack when the call stack is empty and the global code has finished executing.
End of
for
Loop and Logging "End":- Once the
for
loop completes after 9 seconds, "End" is logged to the console, and the call stack is finally empty.
- Once the
Execution of
exec
Function:With the call stack empty, the event loop picks the
exec
function from the event queue and pushes it onto the call stack.The
exec
function is then executed, logging "Executed some task" to the console.
Callbacks: The Legacy Approach
Using callbacks was the traditional way to handle asynchronous operations in JavaScript. However, callbacks have their own set of challenges.
function fetchCustom(url, fn) {
console.log("Start Download for", url);
setTimeout(function process() {
console.log("Download Completed.");
let response = "Dummy data";
fn(response);
console.log("Ending the function");
}, 3000);
}
function writeFile(data, fn) {
console.log("Started writing data", data);
setTimeout(function process() {
console.log("Writing completed");
let filename = "output.txt";
fn(filename);
console.log("writing ended");
}, 4000);
}
function uploadFile(filename, newurl, fn) {
console.log("Upload started");
setTimeout(function process() {
console.log("File", filename, "uploaded successfully on", newurl);
let uploadResponse = "SUCCESS";
fn(uploadResponse);
console.log("upload ended");
}, 2000);
}
fetchCustom("www.google.com", function downloadCallback(response) {
console.log("Downloading response is", response);
writeFile(response, function writeCallback(filenameResponse) {
console.log("new file written is", filenameResponse);
uploadFile(
filenameResponse,
"www.drive.google.com",
function uploadCallback(uploadResponse) {
console.log("Successfully uploaded", uploadResponse);
}
);
});
});
Problems with Callbacks:
Callback Hell: Callbacks within callbacks create a pyramid structure, making code hard to read and maintain.
Inversion of Control: When passing a callback, you give control of its execution to another function, making it difficult to predict and manage.
fn(response); Why Use a Callback Function Here?
When dealing with asynchronous operations like downloading data, we can't simply return the result as we would in synchronous code. This is because the operation takes some time to complete, and the function might return before the operation finishes.
fn
is the callback function passed as an argument tofetchCustom
.We call
fn
with theresponse
as its argument.This is crucial because
fetchCustom
can't directly return theresponse
from the asynchronous operation. Instead, it passes theresponse
to the callback function, which can then handle the data.
Promises: The Modern Approach
Promises in JavaScript are special objects that enhance code readability and handle asynchronous operations more efficiently. They immediately return from a function, acting as a placeholder for data we expect to receive from a future task.
Key Concepts of Promises:
What Do Promises Do?
Inside JavaScript:
- Promises help manage asynchronous code by acting as a placeholder for a value that is not yet available.
Outside JavaScript:
- Promises sign-up processes to run in the runtime environment and give JavaScript a placeholder with a value property.
Why Use Promises?
Readability: Promises make asynchronous code easier to read and understand.
Control: They provide better control over asynchronous tasks compared to callbacks, reducing callback hell and inversion of control issues.
How Promises Work Behind the Scenes
Properties of a Promise Object
Status:
pending
: The initial state, neither fulfilled nor rejected.fulfilled
: The operation was completed successfully.rejected
: The operation failed.
Value:
When the promise is pending, the value is
undefined
.When fulfilled, the value is updated to the resolved value.
When rejected, the value is an error message.
Fulfillment:
- This is an array of functions attached using
.then()
that are executed once the promise is fulfilled.
- This is an array of functions attached using
Promise Lifecycle
Pending: The promise is created and starts in a pending state.
Fulfilled: Once the asynchronous operation is complete successfully,
resolve
is called, updating the promise status to fulfilled and setting the value.Rejected: If the operation fails,
reject
is called, updating the status to rejected and setting the error value.
Creating a Promise
A promise is created using the Promise
constructor, which takes a callback function with two parameters: resolve
and reject
. Here's how you create a promise:
function fetch(url) {
return new Promise(function (resolve, reject) {
console.log("Start fetching from", url);
setTimeout(function process() {
let data = "Dummy data";
console.log("Completed fetching the data");
resolve(data); // Call resolve with the data when the task is successful
}, 4000);
});
}
If you want to return something on success, then call the resolve()
function with whatever value you want to return.
When do we consider a promise fulfilled or rejected?
If we call the resolve()
function, we consider it fulfilled. If we call the reject()
function, we consider it rejected.
function demo2(val) {
return new Promise(function (resolve, reject) {
console.log("Start");
setTimeout(function process() {
console.log("Completed timer");
if ((val & 2) === 0) {
resolve("EVEN");
} else {
reject("ODD");
}
}, 10000);
console.log("Somewhere");
});
}
let a = demo2(4);
Promise Creation:
The function
demo2
is called with the argument4
.Inside
demo2
, a new promise is created and returned immediately.When the promise is created, the
console.log("Start")
executes, printing "Start".Next,
console.log("Somewhere")
executes, printing "Somewhere".
Promise State:
At this point, the promise is in the
pending
state because thesetTimeout
function has started but not yet completed.The
setTimeout
function sets a timer for 10 seconds (10000 milliseconds).
Timer Completion:
After 10 seconds, the
setTimeout
callback (process
function) executes, printing "Completed timer".The condition
(val & 2) === 0
checks if the provided value (val
) is even.If
val
is even (in this case,4
), the promise is resolved with the value"EVEN"
.If
val
is odd, the promise is rejected with the value"ODD"
.
Promise Resolution:
Once the timer completes and the condition is checked, the promise state transitions from
pending
to eitherfulfilled
(ifresolve
is called) orrejected
(ifreject
is called).In this example, the promise will be
fulfilled
because4
is even.
Checking Promise State:
Initially, when
let a = demo2(4);
is executed,a
is assigned a promise in thepending
state.After 10 seconds, when the timer completes, the promise transitions to the
fulfilled
state with the value"EVEN"
.
Consuming a Promise in JavaScript
Consuming a promise is a powerful feature in JavaScript that helps us avoid issues like inversion of control. When we call a function that returns a promise, we receive a promise object that can be stored in a variable.
Understanding Promise Execution
Consider this code snippet:
let response = fetchData('www.google.com');
Question:*Will JavaScript wait for the promise to resolve if it involves asynchronous code?*
Answer: If the creation of a promise involves a synchronous piece of code, JavaScript will wait. If it involves an asynchronous piece of code, JavaScript will not wait.
Synchronous Promise Example
function fetchData(url) {
return new Promise(function (resolve, reject) {
console.log("Started downloading from ", url);
for (let i = 0; i < 1000000000; i++) {} // Long synchronous task
resolve("dummy data");
});
}
In this example, the promise involves a long synchronous task (the for loop). JavaScript will wait for this loop to complete before resolving the promise. Once the loop is done, the promise is resolved immediately.
Asynchronous Promise Example
function fetchData(url) {
return new Promise(function (resolve, reject) {
console.log("Started downloading from ", url);
setTimeout(function process() {
let data = "Dummy data";
console.log("Download complete");
resolve(data);
}, 7000);
});
}
In this example, the promise creation involves an asynchronous task (setTimeout
). The promise object is created and returned immediately, with an initial state of pending
. The promise is fulfilled after 7 seconds.
Executing Functions When a Promise is Resolved
To execute functions when a promise is resolved, we use the .then()
method. This method attaches a callback function that executes once the promise is fulfilled. The .then()
method itself returns a new promise.
function fetchData(url) {
return new Promise(function (resolve, reject) {
console.log("Started downloading from ", url);
setTimeout(function process() {
let data = "Dummy data";
console.log("Download complete");
resolve(data);
}, 7000);
});
}
let downloadPromise = fetchData("www.google.com");
downloadPromise.then(function handleResolve(value) {
console.log(value); // Logs "Dummy data"
return "Hi There!";
});
Key Points
Promise Creation and State:
Promises are special JavaScript objects that serve as placeholders for data from future tasks.
They transition through states:
pending
,fulfilled
, andrejected
.
Synchronous vs Asynchronous Execution:
If a promise creation involves synchronous code, JavaScript will wait.
For asynchronous tasks, the promise object is returned immediately in a
pending
state.
Using
.then()
Method:The
.then()
method attaches a callback function to be executed when the promise is fulfilled.This method returns a new promise, allowing for chaining multiple asynchronous operations.
Understanding promises is a significant step towards mastering asynchronous programming in JavaScript. Promises offer a cleaner and more manageable way to handle asynchronous operations compared to traditional callbacks, reducing issues like callback hell and inversion of control. By using promises, we can write more readable and maintainable code that handles asynchronous tasks efficiently.
In this blog, we explored how promises work, how to create and consume them, and how they help manage asynchronous operations. In the next part of this series, we will delve deeper into other asynchronous patterns in JavaScript, including async/await, and much more.
Stay tuned for Part 2, where we'll continue our journey into the world of asynchronous JavaScript!
Subscribe to my newsletter
Read articles from Nikhil Chauhan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by