JavaScript’s promises and async/await might seem complicated at a glance and there are a lot of pitfalls beginners fall for, in this post I will try to explain everything you need to get started.

Callbacks

First we have to take a step back and realize what promises are trying to replace. Callbacks.

In JavaScript some functions can be asynchronous, meaning it might take some time before it has results to give you, and while JavaScript is single-threaded, certain I/O functions can run asynchronously in the host environment (such as the browser or Node.js), allowing multiple operations to make progress concurrently without blocking the main thread.

A better way to explain that is, JavaScript is single threaded and all your code is run on a single thread, but APIs that come from web browser or Node.js can be multi-threaded behind the scenes and may take a while to respond without blocking your code.

This means if program execution is not blocked, nor the results are returned right away, we need a way to communicate when those results are ready. The good old fashioned way was callbacks, the idea is simple, when you call a function, you give it another function for it to call back later when it actually has the results.

doSomething((data) => {
    console.log('Results are ready');
    // use data
});

console.log('We are alive');

In this scenario, the function doSomething is asynchronous, it’ll do some work that’ll return at a later time, and it won’t block the main thread hence we will see the last console.log run first, then later once results are out, the callback is called and we perform operations on the results in there, simple right?

Callback Hell

Where callbacks break apart is when you have multiple operations that you need the results first before moving to another, this results in deep nested code like this:

getData((data) => {
    modifyData(data, (newData) => {
        saveData(newData, () => {
            console.log('Data saved');
        });
    });
});

This is messy, if we need to wait for one operation to finish we’ll have to keep chaining more callbacks inside the callback, I did not even get into error handling! There is no standard way to handle errors but usually the function would describe a certain argument (usually the first) to be the error if any, that’ll lead to:

getData((err, data) => {
    if (err) {
        console.error(err);
        return;
    }

    modifyData(data, (err, newData) => {
        if (err) {
            console.error(err);
            return;
        }

        saveData(data, (err) => {
            if (err) {
                console.error(err);
                return;
            }

            console.log('Data saved');
        });
    });
});

As you may see, this will quickly spiral out of control in any real business logic.

Promises

The solution to this was promises, the idea is simple a Promise object represents… well a promise, for some data to arrive later. The benefit is that since we are now working with an object, it introduces functionality like standard and consistent error handling, and chaining results.

Here’s we use it, we can create a Promise object and it takes a callback with two functions resolve and reject the callback does its asynchronous work and ends with a call to resolve with data or a call to reject if any error happened.

const promise = new Promise((resolve, reject) => {
    resolve('Hello, World!');
});

promise
    .then(data => console.log(data))
    .catch(err => console.error(err));

In this example we create a promise object and it just resolves directly to Hello, World! which is pretty useless here but it demonstrates the basic idea.

Initially the promise starts in a ‘pending’ state, you can’t just use the value as it is, you can use .then to attach a callback to handle the results when it’s out.

Likewise a .catch can attach an error handler callback.

A common thing in the past when promises were still being adopted was to convert old callback based functions into promise based ones, let’s take Node.js’s fs.readFile for example:

const fs = require('fs');

function readFilePromise(file) {
    return new Promise((resolve, reject) => {
        fs.readFile(file, (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

Now this function will call the callback-based fs.readFile but will wrap it in Promises so it’s more modern.

Chaining

Okay so what did promises even solve there? You still attach callbacks, suppose you wanted to read 3 files and each file had the filename for the next file to read (so we can simulate the need for waiting for one function to return results before the next), we can use the previous function we built here

readFilePromise('file.txt').then(nextFile => {
    readFilePromise(nextFile).then(nextFile => {
        readFilePromise(nextFile).then(data => {
            console.log(data);
        });
    });
});

Ok what did we even solve? that’s a callback hell, we didn’t even handle errors! Fret not, this is not how you are supposed to use Promises. .then can actually be chained, like this:

readFilePromise('file.txt')
    .then(nextFile => readFilePromise(nextFile))
    .then(nextFile => readFilePromise(nextFile))
    .then(nextFile => readFilePromise(nextFile))
    .then(data => console.log(data))
    .catch(err => console.error(err)); 

Now we are talking! Whenever you call .then it actually returns another promise that will resolve with whatever the .then callback returns. So we can chain the callbacks instead of nesting them deep.

Lastly the .catch error handler applies to the entire chain, we don’t need an error handler for each function call, the entire chain can be caught with one .catch

What .then returns does not have to be a promise, you can return anything:

getData()
    .then(data => data.users.find(x => x.name === 'John'))
    .then(user => user.email)
    .then(email => console.log(email));

This would be perfectly fine.

This is cool and all but it still gives asynchronous code special treatment, mixing it with synchronous code is so inconvenient, error handling uses that .catch thing and won’t respect try/catch handlers, etc, think of it like this:

function doStuff() {
    return new Promise((resolve, reject) => {
        try {
            const data = doSyncStuff();
            doAsyncStuff()
                .then(data2 => resolve([data, data2]))
                .catch(err => reject(err));
        } catch (err) {
            reject(err);
        }
    });
}

Things like this could happen! You might need synchronous data which needs error handling via try/catch but try/catch cannot handle promises so you have to apply .catch. Finally the entire function has to be wrapped in a promise for it to even make sense.

Can we do better?

Async/Await

async/await is the solution to most of the previous issues, it makes asynchronous code read exactly like synchronous code rather than needing callbacks for results.

To start using them we mark a function as async

async function doStuff() {
    return 5;
}

It is important to note async/await builds on top of Promises, they are backward compatible, in this example when doStuff() is run, it actually returns a Promise, so we can run .then and .catch like usual.

In this scenario return 5; may seem odd, it wraps an intermediate value into a Promise, but it’s to show you that anything returned by an async function is wrapped inside a Promise object.

The other benefit is that we can call await on promises to wait for its results, the problem is we can only use await in an async environment.

async function sum(x, y) {
    return x + y;
}

async function doMath() {
    const results = await sum(5, 2);
    console.log(results);
    // same as sum(5, 2).then(results => console.log(results))
}

doMath();

In this example we had to wrap our main logic inside a doMath async function so we can use await, a lot of the times we can also use an anonymous function

(async () => {
    await sum(5, 2);
    await sum(6, 9);
})();

This is known as an IIFE, it has no name and is immediately invoked, just to enter an async environment.

Now we can call promises like synchronous functions with await and it fits well with synchronous code because things like try/catch can be used. Recall the horrible promise version we used earlier? let’s rewrite that

async function doStuff() {
    const data = doSyncStuff();
    const data2 = await doAsyncStuff();

    return [data, data2];
}

That’s so much better! We don’t even need the try/catch because all it did there was to catch the error so it can reject the promise, but with async/await any errors thrown will automatically reject the promise.

That goes for my main explanation of these concepts, next I’d like to give some tips and tricks and additional pitfalls beginners fall into.

Top-level Await

While it was common to need an async top level function or an IIFE to start running code with await, in modern times with ES Modules, we have top-level await, meaning we can just use await as it is without any functions!

await doStuff();

To use this in Node.js you should have "type": "module" in your package.json but keep in mind your entire module system changes, typically you’d wanna switch from using require to import

require in its nature was synchronous, it’d run the file and return the results, with ES Modules we have a new import() function that is async so you’d have to use await import('...') if you were dynamically importing a file, since now they behave as if wrapped inside an async function by default.

Care must be taken when using this inside modules that are supposed to be imported as it can delay importing until all promises resolve.

Promises can return promises

Consider this scenario

async function doStuff() {
    return await doMoreStuff();
}

This function just passes control to another function and returns their results, but you don’t need await here at all.

async function doStuff() {
    return doMoreStuff();
}

If you return a promise, that promise is returned, there’s no need to await it only to wrap it back into a promise. In fact you don’t even need async here

function doStuff() {
    return doMoreStuff();
}

assuming doMoreStuff() is returning a promise, doStuff now returns a promise, period.

But watch for the pitfalls

Consider this code

async function doStuff() {
    try {
        return doMoreStuff();
    } catch (err) {
        // unreachable
        console.error(err);
        return null;
    }
}

In this scenario the try/catch is invalid! The promise is returned to the caller and they are responsible for handling the error, so if an error happens, that catch is never called! An exception is if doMoreStuff throws a synchronous exception rather than/before a promise rejection

In this scenario you do want to locally await the promise so we can fulfill our logic before handing it to the caller

async function doStuff() {
    try {
        return await doMoreStuff(); // <-- make sure to await
    } catch (err) {
        console.error(err);
        return null;
    }
}

Parallel Execution

What if you don’t need the promise’s results, hence you don’t await it, you just want it to run.

Consider this code

async function doStuff() {
    try {
        doMoreStuff(); // <-- async but not awaited on purpose.
        doEvenMoreStuff(); // <-- also async but not awaited on purpose
    } catch (err) {
        console.error(err);
    }
}

The idea behind this that you want both to run at the same time, we don’t care about the results, but still handle errors, well this won’t work! The catch will never get it and it’ll result in an unhandled rejection which can crash the program depending on the runtime.

What do we do? await will fix this but we also don’t want to wait for one to finish before starting the other.

In this scenarios we have functions like Promise.all which waits until all promises are fulfilled but not in any specific order or rejects early if any of the promises reject.

async function doStuff() {
    try {
        await Promise.all([doMoreStuff(), doEvenMoreStuff()]);
    } catch (err) {
        console.error(err);
    }
}

Now both functions run, and we wait for both to finish but they can finish in any order, but Promise.all will still preserve the results order.

Here’s a real-world useful example for Discord Bots

try {
    await Promise.all([
        message.channel.startTyping(),
        command.execute(message, args)
    ]);
} catch (err) {
    await message.reply('Command failed');
    console.error(err);
}

We want to start typing and run the command, however typing is supposed to indicate that the bot is doing work, we don’t need to wait for the bot to start typing and delay the command, it’ll be done together as the command is running.

  • The Promise.all expression rejects as soon as the first promise rejects.
  • You can use Promise.allSettled if you want to allow some of the promises to reject.

Await only pauses the current function

It is important to know that await only pauses the current function to wait for the results of a call, during that time the function is ‘suspended’ meaning control is given back to the program flow and other things could run in the meantime

async function main() {
    const data = await getData(); // <-- main() suspends
    console.log(data);
}

main();
console.log('Hello, World!'); // <-- this runs first because main is suspended as it waits for getData() to later resume.

Unhandled Rejections

If a promise goes unhandled, it’s considered an unhandled rejection and may behave differently depending on the runtime, in Node.js it emits an event called unhandledRejection on the process object which can be handled by:

process.on('unhandledRejection', (err) => {
    console.error('UNHANDLED PROMISE');
    console.error(err);
    process.exit(1);
});

Warning

It is not recommended to use a handler to prevent the program from exiting, which could be in an unpredictable state. Instead fix the rejection errors from their source.

Use Node.js Modern Promise alternatives

In the old days everything in Node.js was callback based, like fs and you had to wrap it in a Promise manually, or a function like util.promisify

Nowadays Node.js implements alternatives to certain builtin modules to include a version that is promise ready, such as fs/promises

// import from module directly
const { readFile } = require('fs/promises');
// ES Modules
import { readFile } from 'fs/promises';

await readFile('file.txt');