In this article, we discover the problems with synchronous JavaScript and how we can solve them with the asynchronous techniques of callbacks, promises, and async/await.
We go through the three ways one by one with examples to discover how JavaScript has evolved in this area in recent years. However, before looking into these techniques, let’s look into the difference between synchronous and asynchronous code.
Synchronous code
JavaScript is a single-threaded programming language, which means only one thing can happen at a time. While a single thread simplifies writing and reasoning about code, this also has some drawbacks.
Imagine we do a long-running task like fetching a resource over the network. Now we block the browser until the resource is downloaded. This can make for a bad user experience and might result in the user leaving our page.
When we execute code synchronously, we wait for it to finish before moving to the next task. Nothing else can happen while each operation is being processed — rendering is paused.
Let’s write some code to clarify:
function logFunction() {
console.log('in logFunction');
}
console.log('Start');
logFunction();
console.log('End');
// -> Start
// -> in logFunction
// -> End
This code executes as expected.
- We log “Start”.
- We execute the function which logs “in logFunction”
- We log “End”.
So, synchronous tasks must be aware of one another and be executed in sequence.
Asynchronous code
That’s where asynchronous JavaScript comes into play. Using asynchronous JavaScript, we can perform long-lasting tasks without blocking the main thread. When we execute something asynchronously, we can move to another task before it finishes.
The event loop is the secret behind JavaScript’s asynchronous programming. JavaScript executes all operations on a single thread, but using a few clever data structures gives us the illusion of multi-threading. If you want to understand what happens under the hood in the following examples, you should read more about the concurrency model and the event loop.
Let’s do another example, this time using setTimeout()
, that allows us to wait a defined number of milliseconds before running its code:
console.log('Start');
setTimeout(() => {
console.log('In timeout');
}, 1000); // Wait 1s to run
console.log('End');
// -> Start
// -> End
// -> In timeout
Did you expect “In timeout” to be logged before “End”?
We are not blocking the code execution but instead, we continue and come back to run the code inside setTimeout
one second later.
Let’s look at another example. When we fetch an image from a server, we can’t return the result immediately. That means that the following wouldn’t work:
let response = fetch('myImage.png'); // fetch is asynchronous
let blob = response.blob();
That’s because we don’t know how long the image takes to download, so when we run the second line, it throws an error because the response is not yet available. Instead, we need to wait until the response returns before using it.
Let’s look at how we would solve this with asynchronous code.
Callbacks
This approach to asynchronous programming is to make slow-performing actions take an extra argument, a callback function. When the slow action finishes, the callback function is called with the result.
As an example, the setTimeout
function waits a given number of milliseconds before calling a function. We can simulate slow asynchronous tasks without calling the backend this way:
setTimeout(() => console.log('One second later.'), 1000);
While the concept of callbacks is great in theory, it can lead to confusing and difficult-to-read code. Just imagine making callback after callback:
getData(function(a) {
getMoreData(a, function(b) {
getMoreData(b, function(c) {
getMoreData(c, function(d) {
// ...
});
});
});
});
Nested callbacks going several levels deep are sometimes called callback hell. Each new callback level makes the code more difficult to understand and maintain. Using callbacks is not common these days, but if we get unlucky we might find them in legacy code bases.
Next, we look into how modern JavaScript has tried to solve this problem.
Promises
Promises, introduced with ES6, are a new way of dealing with asynchronous operations in JavaScript. A promise is an object that might produce a value in the future. Just like in real life, we don’t know if the promise will be kept and we use the promise object as a placeholder while we wait for the outcome.
const promise = new Promise();
Having an object as a proxy for future values lets us write the code in a synchronous manner. We get the promise object and continue executing the code. But, there is a bit more to it, as we will see.
The promise constructor takes one argument, a callback with two parameters, one for success (resolve) and one for fail (reject). We need to either resolve a promise if it’s fulfilled or reject it if it failed:
const promise = new Promise((resolve, reject) => {
// Do stuff
if (/* fulfilled */) {
resolve('It worked!');
} else {
reject(Error('It failed!'));
}
});
States
A promise in JavaScript is similar to a promise in real life. It will either be kept, (fulfilled), or it won’t (rejected).
A promise can be:
*pending *— Initial state, not fulfilled or rejected yet.
*fulfilled *— The operation succeeded.
resolve()
was called.*rejected *— The operation failed.
reject()
was called.settled— Has fulfilled or rejected.
After a promise is settled it can not change its state anymore.
Resolve
Let’s create a promise and resolve it:
const promise = new Promise((resolve, reject) => {
resolve('We are done.');
});
console.log(promise);
// -> Promise {<fulfilled>: "We are done."}
We can see that resolving the promise resulted in a fulfilled state.
Now that we have created a promise, let’s see how to use it.
Then
To access the value passed by the resolve
or reject
functions, we can use then()
. It takes two optional arguments, a callback for a resolved case and another for a rejected one.
In this case, we get its resolved value by using the then()
method:
const promise = new Promise((resolve, reject) => {
resolve('We are done.');
});
promise.then((result) => console.log(result));
// -> We are done.
A promise can only resolve or reject once.
Chaining
Since then()
returns a new promise, it can be chained. Like synchronous code, chaining results in a sequence that runs in serial.
Consider this simplified example where we fetch some data:
fetch(url)
.then(processData)
.then(saveData)
.catch(handleErrors);
Assuming each function returns a promise, saveData()
waits for processData()
to complete before starting, which in turn waits for fetch()
to complete. handleErrors()
only runs if any of the previous promises reject.
The possibility of chaining is one of the advantages of using Promises compared to callbacks.
Error handling
When a promise rejects, the control jumps to the closest rejection handler. The catch()
doesn’t have to be immediately after, it may instead appear after one or multiple then()
.
const promise = new Promise((resolve, reject) => {
reject('We failed.');
});
promise
.then((response) => response.json())
.catch((error) => console.log(error));
// -> We failed.
We should end all promise chains with a catch()
.
Promises are commonly used when fetching data over a network or doing other kinds of asynchronous programming in JavaScript and have become an integral part of modern JavaScript.
Next, let’s take a look at async/await
.
Async and Await
Async functions and the await keyword, new additions with ECMAScript 2017, act as syntactic sugar on top of promises allowing us to write synchronous-looking code while performing asynchronous tasks behind the scenes.
Async
First, we have the async
keyword. We put it in front of a function declaration to turn it into an async function.
async function getData(url) {}
Invoking the function now returns a promise. This is one of the traits of async functions — their return values are converted to promises.
Async functions enable us to write promise-based code as if it were synchronous, but without blocking the execution thread and instead operating asynchronously.
However, async
alone does not make the magic happen. The next step is to use the await
keyword inside the function.
Await
The real advantage of async functions becomes apparent when you combine them with the await
keyword. Await can only be used inside an async block, where it makes JavaScript wait until a promise returns a result.
let value = await promise
The keyword await
makes JavaScript pause at that line until the promise settles and returns its result, and then resumes code execution.
It’s a more elegant syntax of getting the result from a promise than promise.then()
.
Fetch
fetch()
allows us to make network requests similar to XMLHttpRequest
(XHR). The main difference is that the Fetch API uses promises, which enables a simpler and cleaner API, avoiding callbacks.
The simplest use of fetch()
takes one argument — the path to the resource — and returns a promise containing the response.
async getData(url) {
const data = await fetch(url);
return data;
}
In our code, we now wait for fetch()
to return with the data before we return it from the function.
Now, we have our function ready. Remember, since it returns a promise, we need to use then()
to get hold of the value.
getData(url).then((data) => console.log(data));
Or we could even write this shorthand:
getData(url).then(console.log);
We have all the basics of expected behavior figured out now, but what if something unexpected happens?
Error handling
If await promise
is rejected, it throws the error, just as if there were a throw
statement at that line. We can catch that error using try/catch
, the same way as in regular code where an error is thrown.
async getData(url) {
try {
const data = await fetch(url);
return data;
} catch(error) {
// Handle error
}
}
If we don’t have the try/catch
, the promise generated by calling the async function becomes rejected. We can append catch()
to handle it:
getData(url).catch(alert);
If we don’t add a catch()
, we get an unhandled promise error. We could catch such errors using a global error handler.
Example with fetch
For testing purposes, it’s often a good idea to start by making the data available locally. We can do this by creating a json file with the data. Since we can use fetch()
to get the data from it just like we would do with a remote call it’s easy to replace the URL from local to remote.
We can’t use fetch directly on the file system so we need to run a webserver to serve our file.
Http-server
If we don’t have a web server on our machine, we can use the simple, zero-configuration command-line http-server. You need Node.js installed on your machine. Npx is a tool for executing Node packages, and we can use it to run our server:
npx http-server
It serves files from the folder we run the command from. When the server starts, it tells you which address to go to in your browser to run the application:
Now that we are serving the file, we can start the actual coding.
Fetch
First, we create a file data.json
where we can save the data in JSON format. Next, we write an *async function *getData()
to get the data from the local json file:
async function getData() {
const data = await fetch('data.json')
.then((response) => response.json())
.catch((error) => console.log(error))
|| [] // Default if file is empty;
return data;
}
The response from fetch is an HTTP response, not the actual JSON. To extract the JSON body content from the response, we use the json()
method. Running the code retrieves the data from the local file.
Conclusion
When we have code that doesn’t complete immediately, we need to wait for it to finish before continuing. This is where asynchronous JavaScript comes in. We looked into the differences between synchronous and asynchronous JavaScript and how we first tried solving it with callbacks.
Next, we learned about promises, and how they solve some of the problems we had with callbacks. Promises are commonly used when fetching data over a network or doing other kinds of asynchronous programming in JavaScript. They have become an integral part of modern JavaScript and as such, are important for JavaScript developers to master.
Async/await provides a nice, simplified way to write async code that is simpler to read and maintain. The async
keyword tells that functions return a promise rather than directly returning the value. The await
keyword can only be used inside an async block, where it makes JavaScript wait until a promise returns a result.
I hope that after reading this, you have a better understanding of asynchronous JavaScript and the different techniques we can use to implement it.
Top comments (1)
Very well written article.