Key Takeaways
- JavaScript’s single-thread processing allows for asynchronous programming, where code execution doesn’t need to wait for a task to finish before moving on to the next task. This is achieved through callbacks, promises, and async/await.
- Callbacks are functions that are passed as arguments to other functions and are invoked after a task completes. However, when dealing with multiple nested callbacks, it can lead to “callback hell,” making the code difficult to read and manage.
- Promises, introduced in ES6, provide a cleaner syntax to handle asynchronous operations. They return a promise object that runs either a resolve function when processing completes successfully or a reject function when a failure occurs. They can be chained to run asynchronous commands in series.
- ES7 introduced async/await, which makes promises easier to manage by making asynchronous code look and behave more like synchronous code. The async function always returns a promise and the await keyword makes JavaScript wait until the promise settles and returns its result. Despite some pitfalls, async/await is a significant addition to JavaScript.
In this article, we’ll take a high-level look at how to work with asynchronous code in JavaScript. We’ll start off with callbacks, move on to promises, then finish with the more modern async/await
. Each section will offer example code, outline the main points to be aware of, and link off to more in-depth resources.
Contents:
JavaScript is regularly claimed to be asynchronous. What does that mean? How does it affect development? How has the approach changed in recent years?
Consider the following code:
result1 = doSomething1();
result2 = doSomething2(result1);
Most languages process each line synchronously. The first line runs and returns a result. The second line runs once the first has finished — regardless of how long it takes.
Single-thread Processing
JavaScript runs on a single processing thread. When executing in a browser tab, everything else stops. This is necessary because changes to the page DOM can’t occur on parallel threads; it would be dangerous to have one thread redirecting to a different URL while another attempts to append child nodes.
This is rarely evident to the user, because processing occurs quickly in small chunks. For example, JavaScript detects a button click, runs a calculation, and updates the DOM. Once complete, the browser is free to process the next item on the queue.
(Side note: other languages such as PHP also use a single thread but may be managed by a multi-threaded server such as Apache. Two requests to the same PHP page at the same time can initiate two threads running isolated instances of the PHP runtime.)
Going Asynchronous with Callbacks
Single threads raise a problem. What happens when JavaScript calls a “slow” process such as an Ajax request in the browser or a database operation on the server? That operation could take several seconds — even minutes. A browser would become locked while it waited for a response. On the server, a Node.js application would not be able to process further user requests.
The solution is asynchronous processing. Rather than wait for completion, a process is told to call another function when the result is ready. This is known as a callback, and it’s passed as an argument to any asynchronous function.
For example:
doSomethingAsync(callback1);
console.log('finished');
// call when doSomethingAsync completes
function callback1(error) {
if (!error) console.log('doSomethingAsync complete');
}
The doSomethingAsync
function accepts a callback as a parameter (only a reference to that function is passed, so there’s little overhead). It doesn’t matter how long doSomethingAsync
takes; all we know is that callback1
will be executed at some point in the future. The console will show this:
finished
doSomethingAsync complete
You can read more about callbacks in Back to Basics: What are Callbacks in JavaScript?
Callback Hell
Often, a callback is only ever called by one asynchronous function. It’s therefore possible to use concise, anonymous inline functions:
doSomethingAsync(error => {
if (!error) console.log('doSomethingAsync complete');
});
A series of two or more asynchronous calls can be completed in series by nesting callback functions. For example:
async1((err, res) => {
if (!err) async2(res, (err, res) => {
if (!err) async3(res, (err, res) => {
console.log('async1, async2, async3 complete.');
});
});
});
Unfortunately, this introduces callback hell — a notorious concept that even has its own web page! The code is difficult to read, and will become worse when error-handling logic is added.
Callback hell is relatively rare in client-side coding. It can go two or three levels deep if you’re making an Ajax call, updating the DOM and waiting for an animation to complete, but it normally remains manageable.
The situation is different for OS or server processes. A Node.js API call could receive file uploads, update multiple database tables, write to logs, and make further API calls before a response can be sent.
You can read more about callback hell in Saved from Callback Hell.
Promises
ES2015 (ES6) introduced promises. Callbacks are still used below the surface, but promises provide a clearer syntax that chains asynchronous commands so they run in series (more about that in the next section).
To enable promise-based execution, asynchronous callback-based functions must be changed so they immediately return a promise object. That object promises to run one of two functions (passed as arguments) at some point in the future:
resolve
: a callback function run when processing successfully completesreject
: an optional callback function run when a failure occurs
In the example below, a database API provides a connect
method which accepts a callback function. The outer asyncDBconnect
function immediately returns a new promise and runs either resolve
or reject
once a connection is established or fails:
const db = require('database');
// Connect to database
function asyncDBconnect(param) {
return new Promise((resolve, reject) => {
db.connect(param, (err, connection) => {
if (err) reject(err);
else resolve(connection);
});
});
}
Node.js 8.0+ provides a util.promisify() utility to convert a callback-based function into a promise-based alternative. There are a couple of conditions:
- the callback must be passed as the last parameter to an asynchronous function
- the callback function must expect an error followed by a value parameter
Example:
// Node.js: promisify fs.readFile
const
util = require('util'),
fs = require('fs'),
readFileAsync = util.promisify(fs.readFile);
readFileAsync('file.txt');
Asynchronous Chaining
Anything that returns a promise can start a series of asynchronous function calls defined in .then()
methods. Each is passed the result from the previous resolve
:
asyncDBconnect('http://localhost:1234')
.then(asyncGetSession) // passed result of asyncDBconnect
.then(asyncGetUser) // passed result of asyncGetSession
.then(asyncLogAccess) // passed result of asyncGetUser
.then(result => { // non-asynchronous function
console.log('complete'); // (passed result of asyncLogAccess)
return result; // (result passed to next .then())
})
.catch(err => { // called on any reject
console.log('error', err);
});
Synchronous functions can also be executed in .then()
blocks. The returned value is passed to the next .then()
(if any).
The .catch()
method defines a function that’s called when any previous reject
is fired. At that point, no further .then()
methods will be run. You can have multiple .catch()
methods throughout the chain to capture different errors.
ES2018 introduces a .finally()
method, which runs any final logic regardless of the outcome — for example, to clean up, close a database connection, and so on. It’s supported in all modern browsers:
function doSomething() {
doSomething1()
.then(doSomething2)
.then(doSomething3)
.catch(err => {
console.log(err);
})
.finally(() => {
// tidy-up here!
});
}
A Promising Future?
Promises reduce callback hell but introduce their own problems.
Tutorials often fail to mention that the whole promise chain is asynchronous. Any function using a series of promises should either return its own promise or run callback functions in the final .then()
, .catch()
or .finally()
methods.
I also have a confession: promises confused me for a long time. The syntax often seems more complicated than callbacks, there’s a lot to get wrong, and debugging can be problematic. However, it’s essential to learn the basics.
You can read more about promises in An Overview of JavaScript Promises.
async/await
Promises can be daunting, so ES2017 introduced async
and await
. While it may only be syntactical sugar, it makes promises far sweeter, and you can avoid .then()
chains altogether. Consider the promise-based example below:
function connect() {
return new Promise((resolve, reject) => {
asyncDBconnect('http://localhost:1234')
.then(asyncGetSession)
.then(asyncGetUser)
.then(asyncLogAccess)
.then(result => resolve(result))
.catch(err => reject(err))
});
}
// run connect (self-executing function)
(() => {
connect();
.then(result => console.log(result))
.catch(err => console.log(err))
})();
To rewrite this using async/await
:
- the outer function must be preceded by an
async
statement - calls to asynchronous, promise-based functions must be preceded by
await
to ensure processing completes before the next command executes
async function connect() {
try {
const
connection = await asyncDBconnect('http://localhost:1234'),
session = await asyncGetSession(connection),
user = await asyncGetUser(session),
log = await asyncLogAccess(user);
return log;
}
catch (e) {
console.log('error', err);
return null;
}
}
// run connect (self-executing async function)
(async () => { await connect(); })();
await
effectively makes each call appear as though it’s synchronous, while not holding up JavaScript’s single processing thread. In addition, async
functions always return a promise so they, in turn, can be called by other async
functions.
async/await
code may not be shorter, but there are considerable benefits:
- The syntax is cleaner. There are fewer brackets and less to get wrong.
- Debugging is easier. Breakpoints can be set on any
await
statement. - Error handling is better.
try/catch
blocks can be used in the same way as synchronous code. - Support is good. It’s implemented in all modern browsers and Node 7.6+.
That said, not all is perfect …
Promises, Promises
async/await
relies on promises, which ultimately rely on callbacks. This means that you’ll still need to understand how promises work.
Also, when working with multiple asynchronous operations, there’s no direct equivalent of Promise.all or Promise.race. It’s easy to forget about Promise.all
, which is more efficient than using a series of unrelated await
commands.
try/catch Ugliness
async
functions will silently exit if you omit a try/catch
around any await
which fails. If you have a long set of asynchronous await
commands, you may need multiple try/catch
blocks.
One alternative is a higher-order function, which catches errors so that try/catch
blocks become unnecessary (thanks to @wesbos for the suggestion).
However, this option may not be practical in situations where an application must react to some errors in a different way from others.
Yet despite some pitfalls, async/await
is an elegant addition to JavaScript.
You can read more about using async/await
in A Beginner’s Guide to JavaScript async/await, with Examples.
JavaScript Journey
Asynchronous programming is a challenge that’s impossible to avoid in JavaScript. Callbacks are essential in most applications, but it’s easy to become entangled in deeply nested functions.
Promises abstract callbacks, but there are many syntactical traps. Converting existing functions can be a chore and .then()
chains still look messy.
Fortunately, async/await
delivers clarity. Code looks synchronous, but it can’t monopolize the single processing thread. It will change the way you write JavaScript and could even make you appreciate promises — if you didn’t before!
Frequently Asked Questions (FAQs) on Flow Control, Callbacks, Promises, and Async/Await in JavaScript
What is the difference between Callbacks, Promises, and Async/Await in JavaScript?
Callbacks, Promises, and Async/Await are all techniques used in JavaScript to handle asynchronous operations, but they differ in their approach and syntax. Callbacks are functions passed as arguments to other functions and are invoked after the completion of a certain task. However, they can lead to “callback hell” when dealing with multiple nested callbacks. Promises are objects that represent the eventual completion or failure of an asynchronous operation. They provide a cleaner and more manageable way to handle asynchronous operations compared to callbacks. Async/Await is a syntactic sugar over Promises, introduced in ES8, that makes asynchronous code look and behave more like synchronous code, thus making it easier to understand and manage.
How can I avoid Callback Hell in JavaScript?
Callback Hell, also known as Pyramid of Doom, is a situation where callbacks are nested within callbacks, making the code hard to read and understand. There are several ways to avoid Callback Hell. One way is to modularize the code by breaking it into smaller, reusable functions. Another way is to use Promises or Async/Await which provide a cleaner and more manageable way to handle asynchronous operations.
Can I use Async/Await with Promises in JavaScript?
Yes, you can use Async/Await with Promises in JavaScript. In fact, the Async/Await syntax is built on top of Promises. An async function always returns a Promise. The await keyword can only be used inside an async function and it makes JavaScript wait until the Promise settles and returns its result.
What are the advantages of using Promises over Callbacks in JavaScript?
Promises have several advantages over callbacks in JavaScript. They provide a cleaner and more manageable way to handle asynchronous operations. They avoid the problem of Callback Hell or Pyramid of Doom. They also support chaining, which allows you to perform multiple asynchronous operations in a sequence. Moreover, Promises have better error handling capabilities compared to callbacks.
Can I use Callbacks, Promises, and Async/Await together in JavaScript?
Yes, you can use Callbacks, Promises, and Async/Await together in JavaScript. They are not mutually exclusive and can be used in combination depending on the specific requirements of your code. For example, you can use a callback inside a Promise or an async function. However, it’s generally recommended to use Promises or Async/Await for handling asynchronous operations as they provide a cleaner and more manageable way compared to callbacks.
How does error handling work with Callbacks, Promises, and Async/Await in JavaScript?
Error handling works differently with Callbacks, Promises, and Async/Await in JavaScript. In callbacks, errors are typically handled by passing them as the first argument to the callback function. In Promises, errors are handled using the catch method. In Async/Await, errors are handled using the try/catch statement, similar to synchronous code.
What is the difference between synchronous and asynchronous code in JavaScript?
In JavaScript, synchronous code is executed in sequence, meaning each statement waits for the previous statement to finish before executing. On the other hand, asynchronous code doesn’t have to wait and can execute while waiting for other operations to finish. This makes asynchronous code non-blocking and is particularly useful for tasks that take a lot of time, such as network requests, file I/O, etc.
How can I convert a callback-based function to a Promise-based function in JavaScript?
You can convert a callback-based function to a Promise-based function in JavaScript by wrapping the callback-based function inside a new Promise. The resolve and reject parameters of the Promise constructor are used to handle the success and failure of the asynchronous operation, respectively.
Can I use Async/Await in all browsers?
Async/Await is a relatively new feature in JavaScript, introduced in ES8, and is supported in most modern browsers. However, it’s not supported in Internet Explorer and older versions of other browsers. You can use a tool like Babel to transpile your code to ES5 syntax that is compatible with all browsers.
What is the performance impact of using Callbacks, Promises, and Async/Await in JavaScript?
The performance impact of using Callbacks, Promises, and Async/Await in JavaScript is generally negligible for most applications. However, Promises and Async/Await can have a slightly higher memory footprint compared to callbacks due to their additional features. The choice between callbacks, Promises, and Async/Await should be based on factors like code readability, maintainability, and error handling capabilities rather than performance.
Craig is a freelance UK web consultant who built his first page for IE2.0 in 1995. Since that time he's been advocating standards, accessibility, and best-practice HTML5 techniques. He's created enterprise specifications, websites and online applications for companies and organisations including the UK Parliament, the European Parliament, the Department of Energy & Climate Change, Microsoft, and more. He's written more than 1,000 articles for SitePoint and you can find him @craigbuckler.
Network admin, freelance web developer and editor at SitePoint.