Digital Garden
Computer Science
JavaScript
Async and Promises

Async and Promises

Most of the code we write is so-called synchronous code. Synchronous code is when each task is performed one after the other, in sequence, hence the name. This means that in synchronous code, the next task, i.e. line of code does not execute until the previous one has completed.

However, asynchronous code allows for multiple tasks to be performed simultaneously, making it more efficient and faster. The most common example is when making a data request from a client (browser) to a server. Whilst we are waiting for the response to arrive we don't want the website to be unusable/frozen, hence the data request/fetching is done asynchronously.

The difference between synchronous and asynchronous requests.
Info

You might know or have read that javascript only runs in a single thread tho, so how is asynchronous code possible?

In JavaScript, the browser handles asynchronous operations by using browser threads that run independently of the JavaScript main thread. So instead when an asynchronous operation is initiated, the browser registers a callback function and continues executing the rest of the program. When the operation completes, the callback function is called by the browser with the result of the operation.

You cand find more about this topic here (opens in a new tab).

A very simple example of async code can be made using the setTimeout() function which creates an async task where the browser just sleeps and then calls the callback function after a certain amount of time.

console.log('Fetching users...');
 
setTimeout(() => {
    const users = [
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
        { id: 3, name: 'Charlie' }
    ];
    console.log('Users:', users);
}, 2000);
 
console.log('Program continues to execute...');
Fetching users...
Program continues to execute...
Users: [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' } ]

So we can see the that the code carried on after creating the async task. Interestingly because the way the messaging queue works in javascript even if the delay was 0, and we would just have a simple console output in the callback function the console output outside, i.e. the first line after the creation of the async task will always execute before the callback.

Promises

In JavaScript there is a thing callback hell. The code below does the following, whilst handling errors:

  1. Reads the contents of file1.txt and file2.txt.
  2. Then it concatenates the content and writes the result to file3.txt.
  3. Then it reads the content of file4.txt and converts it to uppercase and writes the result to file5.txt.
Info

The code below uses the Node.js File system API and would not work in the browser.

const fs = require('fs');
 
fs.readFile('file1.txt', (err, data) => {
    if (err) {
        console.error(err);
        return;
    }
    const file1Contents = data.toString();
    fs.readFile('file2.txt', (err, data) => {
        if (err) {
            console.error(err);
            return;
        }
        const file2Contents = data.toString();
        fs.writeFile('file3.txt', file1Contents + file2Contents, (err) => {
            if (err) {
                console.error(err);
                return;
            }
            console.log('File3 created with the contents of file1 and file2.');
            fs.readFile('file4.txt', (err, data) => {
                if (err) {
                    console.error(err);
                    return;
                }
                const file4Contents = data.toString();
                fs.writeFile('file5.txt', file4Contents.toUpperCase(), (err) => {
                    if (err) {
                        console.error(err);
                        return;
                    }
                    console.log('File5 created with the uppercase contents of file4.');
                });
            });
        });
    });
});

As you can see it gets very messy and confusing, which is why JavaScript introduced promises. To understand how promises work lets first create a function that transforms the setTimeout() function to use promises.

const setTimer = duration => {
    const promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("Done!");
        }, duration);
    });
    return promise;
};
 
setTimer(2000).then(data => {
    console.log(data);
})

The promise object represents the eventual completion(resolve()) or failure(reject()) of an asynchronous operation and its result. The promise constructor takes a single argument, a function, the so-called executor which is run automatically and is responsible for performing the asynchronous task. The executor takes two arguments, the resolve and reject callback functions which are respectively called if the operation succeeded with the result or if the operation failed with the error. This means that a promise object can be in one of three states:

  • Pending, the initial state whilst doing the work.
  • Fulfilled: a successful completion of the operation, i.e. the promise has resolved and the resulting value is available.
  • Rejected: a failed operation, i.e. the promise has been rejected and the resulting error is available.

Depending on the result of a promise it can be fetched and handled with one of two methods:

  • then(), when the promise is fulfilled.
  • catch(), when the promise is rejected.

Because these methods are also encapsulated in a Promise it allows for Promises to be chained together.

Info

If you have another then() block after a catch() the chain continues, so errors need to propagated (because the outer promise returns to the pending state). Only once there are no more then() blocks left does the outer promise enter the state Settled which can then be handled by the finally() method to do final cleanup work.

finally() is reached no matter if you resolved or rejected before!

const setTimer = duration => {
    const promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("Done!");
        }, duration);
    });
    return promise;
};
 
setTimer(2000).then(data => {
    console.log(data);
    throw new Error("Oh no, it's broken :(");
}).catch(err => {
    console.error("Catch 1");
console.log(err)
}).then(data => {
    console.log("We continue with work");
}).catch(err => {
    console.error("Catch 2");
    console.log(err)
}).finally(() => {
    console.log("All done");
});
Done!
Catch 1
Error: Oh no, it's broken :(
at /tmp/jeGZmdNBei.js:12:11
We continue with work
All done

Luckily the functions readFile() and writeFile() in the "callback hell" example, have also been implemented to use promises if the import is changed slightly. We can then rewrite the above hell to this much more readable code:

const fs = require('fs').promises;
 
fs.readFile('file1.txt')
    .then(file1Contents => {
        return fs.readFile('file2.txt')
            .then(file2Contents => {
        return file1Contents + file2Contents;
        });
    })
    .then(file3Contents => {
        return fs.writeFile('file3.txt', file3Contents);
    })
    .then(() => {
        return fs.readFile('file4.txt');
    })
    .then(file4Contents => {
        return fs.writeFile('file5.txt', file4Contents.toString().toUpperCase());
    })
    .catch(err => {
        console.error(err);
    });

Async and Await

As we can see error handling can still a bit annoying even with promises. The async and await keywords is a syntactical sugar built on top of Promises that makes it easier to write asynchronous code that also looks more like synchronous code. We especially get to see how "synchronous esque" the code is using async and await when handling errors as we can just use try and catch with the expected behaviour.

TODODODODODODODODO explain the difference

function getPromisesData() {
    return fetch('https://dummyjson.com/products')
        .then(response => response.json())
        .then(data => {
            return data;
        })
        .catch(error => {
            console.error("Promise Error 1: " + error);
            throw error; // propagate
        });
}
 
async function getAsyncData() {
    try {
        const response = await fetch('https://dummyjson.com/products');
        const data = await response.json();
        return data;
    } catch (error) {
        console.error("Async Error 1: " + error);
        throw error; // propagate
    }
}
 
getPromisesData()
    .then(data => console.log("Promise Data: " + data))
    .catch(error => console.error("Promise Error 2: " + error));
 
getAsyncData()
    .then(data => console.log("Async Data: " + data))
    .catch(error => console.error("Async Error 2: " + error));