JavaScript is a synchronous language at its core, meaning that only a single line of code runs at any time. That means that time spent executing some part of the code delays the execution of other code. This includes painting things on the screen, handling user input, or doing math.
When building frontend applications we often need to deal with asynchronous operations, such as user input or HTTP requests. In the browser they may run on a separate thread, so how do we deal with this asynchronicity in a synchronous language like JavaScript?
Understanding this part of JavaScript is essential to building real-world applications. And that's what we're going to take a look at in this article.
We'll learn about:
- Callbacks
How callbacks can be used to deal with asynchronous code, and how they lead to callback hell. - Promises
How to use promises to make callbacks manageable. We'll also write our own implementation of the basic promise API to understand how they work. - Async/await
Learn how to use the new await/async API to make promises even easier to work with.
Callback functions
It would be impractical if making an HTTP request blocked the code until we got a response. Even if it came back in 100 milliseconds, that's still ages in computer time. Luckily, JavaScript allows us to provide callback functions to asynchronous APIs.
In fact, you probably use callbacks without even thinking about it.
For instance, user events are asynchronous in nature, and you register callback functions that handle those events, like this:
The example code registers a callback to be executed when the user clicks the button. In the meantime, the program continues running other code and doesn't keep waiting for user input.
Problems with callbacks
Callbacks are the most basic way to deal with asynchronous code. They can actually get you quite far, but at some point, they will start to feel limiting:
- Callbacks create code-flow indirection because the function result is not returned but instead passed into a callback. This makes code harder to follow.
- Handling errors in callbacks is a matter of convention rather than API, which creates inconsistencies and cognitive load.
- Nested callbacks may lead to 'callback hell'. That's when callbacks are nested within other callbacks, several levels deep, often making the code hard to read and understand.
Consider this example of asynchronous JavaScript code using callbacks:
The code isn't overly complicated, but it's already unpleasant to read, because of the nesting. Now consider that in real-world applications we need to deal with asynchronous code quite a bit, and you will realize why it soon feels like being in hell.
Promises
A Promise is a JavaScript object representing a value that will be available after an asynchronous operation completes. Promises can be returned synchronously like regular values, but the value may be supplied at a later point.
It also provides an API to access the "promised" value by binding callbacks for different promise states.
Promise states
Depending on the outcome of the asynchronous operation, promises can be in three states:
- pending: set initially, while the asynchronous operation is in progress;
- fulfilled: the asynchronous operation has finished successfully;
- rejected: the operation has failed.
When a promise enters either fulfilled or rejected state it is said to be settled. The promise is formally said to be resolved if it is settled or resolved with a promise so that further resolving or rejecting it has no effect. However, colloquially it is often meant that the promise is fulfilled.
Working with promises
A promise acts as a synchronous value - it can be passed as an argument or returned by functions as usual. However, to read its eventual value you need to "subscribe" to it. To allow that, promises have public methods:
promise.then
- is a primary method that accepts callbacks to run on success and failure;promise.catch
- is a method to register callbacks for when the promise is rejected;promise.finally
- is a method to register callbacks to be executed after the promise is fulfilled or rejected.
This is how it looks in practice:
As long as the promise is pending, the callbacks will not be executed. Once the promise has been resolved, it will always hold the result value. In case a handler is attached to a resolved handler, the handler simply runs right away and receives the promise value.
Promise chains
A very useful feature of promises is that .then
, .catch
and .finally
calls return promises, which means that we can attach multiple handlers by simply stringing the calls together:
Each handleFulfilled
handler will receive the result returned of calling the previous handler. In case an error is thrown in either hander, the promise will enter the rejected state and any further handlers called will be handleRejected
and .catch
.
Note: a common beginner mistake is to attach multiple handlers on the original promise when trying to chain, it won't work, but it may be a useful feature in some cases:
Remember our function that made tea? Let's see how it looks when rewritten to use promises:
Frankly, it's still not that pretty, but there's less nesting and it doesn't keep going deeper, because the promises are being chained onto the original promise for the most part.
Static methods
Promises have a couple of static methods to help make working with them easier. The most important ones are:
Promise.resolve(value)
- creates a resolved promise with the provided value;Promise.reject(reason)
- creates a rejected promise with the provided value;Promise.race([...promises])
- creates a promise with the value of the first fulfilled or rejected promise in the provided array;Promise.any([...promises])
- creates a promise with the value of the first fulfilled promise in the array, if none are, then the promise is rejected.Promise.all([...promises])
- creates a promise that resolves to an array of the fulfilled promise values. If any promise in the array rejects, the returned promise is rejected.Promise.allSettled([...promises])
- creates a promise that's resolved when all of the promises in the array have been resolved (fulfilled or rejected). As a value, it receives an array of objects, that containstatus
andvalue
orreason
properties, representing the outcomes of each promise.
To really understand how promises work, I recommend you to understand how they can be implemented. I guarantee that most frontend developers won't be able to implement promises, simply because they don't understand them.
Well, now you will.
Implementing the Basic Promise API
Promises are the foundation for doing asynchronous work in JavaScript, so it's imperative to understand them completely. When I was just beginning with JavaScript, some aspects of promises seemed magical. However, that only pointed to my incomplete understanding.
The best way to dispel any magic is to build something from scratch. That way, we can understand how things work under the hood. Trust me when I say this - if you learn how to implement the promise API from scratch you will understand them better than most software developers.
For our purposes, I'll call our promise Futurable
, with otherwise the same API.
1) Tests
Let's first start by writing some tests, so that we're sure we cover the complete API. We'll not spend time on this part, so if you're following along just copy-paste the code. We want to test at least these cases:
- The promise can be resolved with a value;
- The promise can be rejected by calling
reject()
or throwing an error; - The promise can be rejected or resolved with another promise;
- Resolved and rejected promises can't be further rejected or resolved;
- Multiple
then()
,catch()
,finally()
handlers can be chained; finally()
must be invoked on either rejection or fulfillment.- Promise operations should always be asynchronous;
Here's the test suite I will be using:
import Futurable from "./Futurable";
describe("Futurable <constructor>", () => {
it(`returns a promise-like object,
that resolves it's chain after invoking <resolve>`, (done) => {
new Futurable<string>((resolve) => {
setTimeout(() => {
resolve("testing");
}, 20);
}).then((val) => {
expect(val).toBe("testing");
done();
});
});
it("is always asynchronous", () => {
let value = "no";
new Futurable<string>((resolve) => {
value = "yes;";
resolve(value);
});
expect(value).toBe("no");
});
it("resolves with the returned value", (done) => {
new Futurable<string>((resolve) => resolve("testing")).then((val) => {
expect(val).toBe("testing");
done();
});
});
it("resolves a Futurable before calling <then>", (done) => {
new Futurable<string>((resolve) =>
resolve(new Futurable((resolve) => resolve("testing")))
).then((val) => {
expect(val).toBe("testing");
done();
});
});
it("resolves a Futurable before calling <catch>", (done) => {
new Futurable<string>((resolve) =>
resolve(new Futurable((_, reject) => reject("fail")))
).catch((reason) => {
expect(reason).toBe("fail");
done();
});
});
it("catches errors from <reject>", (done) => {
const error = new Error("Why u fail?");
new Futurable((_, reject) => {
return reject(error);
}).catch((err: Error) => {
expect(err).toBe(error);
done();
});
});
it("catches errors from <throw>", (done) => {
const error = new Error("Why u fail?");
new Futurable(() => {
throw error;
}).catch((err) => {
expect(err).toBe(error);
done();
});
});
it("does not change state anymore after promise is fulfilled", (done) => {
new Futurable((resolve, reject) => {
resolve("success");
reject("fail");
})
.catch(() => {
done.fail(new Error("Should not be called"));
})
.then((value) => {
expect(value).toBe("success");
done();
});
});
it("does not change state anymore after promise is rejected", (done) => {
new Futurable((resolve, reject) => {
reject("fail");
resolve("success");
})
.then(() => {
done.fail(new Error("Should not be called"));
})
.catch((err) => {
expect(err).toBe("fail");
done();
});
});
});
describe("Futurable chaining", () => {
it("resolves chained <then>", (done) => {
new Futurable<number>((resolve) => {
resolve(0);
})
.then((value) => value + 1)
.then((value) => value + 1)
.then((value) => value + 1)
.then((value) => {
expect(value).toBe(3);
done();
});
});
it("resolves <then> chain after <catch>", (done) => {
new Futurable<number>(() => {
throw new Error("Why u fail?");
})
.catch(() => {
return "testing";
})
.then((value) => {
expect(value).toBe("testing");
done();
});
});
it("catches errors thrown in <then>", (done) => {
const error = new Error("Why u fail?");
new Futurable((resolve) => {
resolve();
})
.then(() => {
throw error;
})
.catch((err) => {
expect(err).toBe(error);
done();
});
});
it("catches errors thrown in <catch>", (done) => {
const error = new Error("Final error");
new Futurable((_, reject) => {
reject(new Error("Initial error"));
})
.catch(() => {
throw error;
})
.catch((err) => {
expect(err).toBe(error);
done();
});
});
it("short-circuits <then> chain on error", (done) => {
const error = new Error("Why u fail?");
new Futurable(() => {
throw error;
})
.then(() => {
done.fail(new Error("Should not be called"));
})
.catch((err) => {
expect(err).toBe(error);
done();
});
});
it("passes value through undefined <then>", (done) => {
new Futurable((resolve) => {
resolve("testing");
})
.then()
.then((value) => {
expect(value).toBe("testing");
done();
});
});
it("passes value through undefined <catch>", (done) => {
const error = new Error("Why u fail?");
new Futurable((_, reject) => {
reject(error);
})
.catch()
.catch((err) => {
expect(err).toBe(error);
done();
});
});
});
describe("Futurable <finally>", () => {
it("it is called when Futurable is resolved", (done) => {
new Futurable((resolve) => resolve("success")).finally(() => {
done();
});
});
it("it is called when Futurable is rejected", (done) => {
new Futurable((_, reject) => reject("fail")).finally(() => {
done();
});
});
it("it preserves a resolved promise state", (done) => {
let finallyCalledTimes = 0;
new Futurable((resolve) => resolve("success"))
.finally(() => {
finallyCalledTimes += 1;
})
.then((value) => {
expect(value).toBe("success");
expect(finallyCalledTimes).toBe(1);
done();
});
});
it("it preserves a rejected promise state", (done) => {
let finallyCalledTimes = 0;
new Futurable((_, reject) => reject("fail"))
.finally(() => {
finallyCalledTimes += 1;
})
.catch((reason) => {
expect(reason).toBe("fail");
expect(finallyCalledTimes).toBe(1);
done();
});
});
});
Futurable.ts
file if you want to start fresh. Switch to "Tests" tab to see the results.2) The interface
Now we move on to the implementation, and the first thing we need to do is to define the public API.
I took the types mostly from the ES6 promise type definitions so that our implementation gets as close to the standard as possible.
Our constructor will take the callback as a parameter and call it passing resolve
and reject
functions.
Remember how I mentioned that then()
accepts callbacks for both fulfillment and rejection? Well, that's what we define here. It's going to be the basis for registering our handlers - catch and finally is only 'sugar', as you will see later.
3) State machine
I've already mentioned how a promise has 3 states: fulfilled, rejected, and pending. This can be expressed as a state machine:
The promise starts off as pending and moves into resolved state or rejected state. After it makes the transition, it can no longer be resolved, rejected, or enter the pending state.
To implement this, first, we need to define the internal state and the result of the promise, let's do that:
enum FuturableState {
Pending,
Resolved,
Rejected
}
class Futurable<T> {
private state = FuturableState.Pending;
private result?: any;
// ...
}
Now we can implement a function that handles these transitions:
const isThenable = (obj: any): obj is Futurable<any> =>
typeof obj?.then === "function";
class Futurable<T> {
// ...
private resolve = (value: T | Futurable<T>) => {
// TODO: implement
};
private reject = (reason?: any) => {
// TODO: implement
};
private setResult = (value: any, state: FuturableState) => {
if (this.state !== FuturableState.Pending) {
return;
}
if (isThenable(value)) {
value.then(this.resolve, this.reject);
return;
}
this.state = state;
this.result = value;
};
}
The setResult
method is now responsible for making a transition and setting the promise result, let's understand what the code does.
- We only need to allow transitions from pending state, otherwise, calling
setResult
will do nothing, so we exit the function by returning. - We check if the value provided was in itself a promise and resolve it instead of transitioning the state. We also defined the
resolve
andreject
internal functions, which we'll implement later. - If the value was not a promise, we transition the state and value.
then
method, then it should act like a promise, even if it isn't an instance of a Promise
class - it's called a thenable. Now that's pretty cool! That means, that we could resolve Futurable
with new Promise(...)
as a result and it would still work, and vice versa.4) Resolve / Reject
Now that we have a function that handles our promise's state transitions, we can use it to implement the constructor and resolve/reject functions. It's as simple as calling our setResult
function in resolve
and reject
function and then using them in our constructor:
You may notice the try-catch block and wonder why we need it in the constructor. That's because calling reject
is not the only way to reject a promise, we can also throw
an error. Doing that will trigger our catch
block, and we handle the error by rejecting the promise.
Now we have the ability to create a new Futurable
promise and run the callback. Resolving or rejecting it in the callback will change the internal promise state and set the value:
const resolvedPromise = new Futurable((resolve, reject) => {
resolve('success!')
})
const rejectedPromise = new Futurable((resolve, reject) => {
reject('fail...')
})
However, there's one issue...
5) Executing promises asynchronously
Constructing a new promise will immediately execute the callback. This means that our Promises are blocking the code execution, which means they're not actually asynchronous operations.
If you want to see the problem in action, run this code:
Let's fix that.
Without going deep into how the event loop works, we should at least know that our code is being run synchronously in these phases:
- Executing tasks - your code;
- Executing jobs - native ES6 Promise callbacks;
- Executing messages - events, callbacks, ajax requests, etc.
What this means in practice is that your code is run synchronously at the highest priority - as long as it's running, it will block the execution of any code that follows. When the code execution phase is over, it will process any jobs and messages that have been received in the meanwhile.
(true) { console.log('blocking'); console.log('not blocking'); }
in your browser console for example. You will never see the message 'not blocking'. Do it at your own peril though, you may not be able to close the tab anymore and may have to kill the browser.Since we're doing a custom implementation of the promises, we assume that there's no native support for Promises, but we still need to execute the Futurable
callbacks asynchronously. To do that we can utilize a well-known hack: setTimeout(callback, 0);
This would delegate the execution of the callback to run immediately after any synchronous code, hence zero milliseconds. Let's write a utility function:
We can now wrap our constructor and execute
code with this utility to run them asynchronously as such:
If you test the previous example again, you will see that it prints false
, which means that the promise callback code isn't run immediately and doesn't block the code execution anymore.
6) Executing handlers
Promises allow us to access their value by attaching then
or catch
handlers and providing them with callbacks which then receive value asynchronously. Because the callbacks are supposed to be executed one by one sequentially, we can put them in a queue.
How this is going to work:
- Whenever
.then
or.catch
is called, we'll push the callback handler into a queue. There will always be a handler for both, except one of them will simply pass the promise value through as a result, like this(value) => value
. This will ensure, that the whole chain of handlers gets executed, but irrelevant handlers are "skipped", e.g. skip all.catch
calls in case there was no error. - We will execute all of the handlers when the promise is fulfilled or rejected.
- We will execute the resolvers as soon as a handler is pushed into the queue, in case the handler is attached to the promise that has already been resolved. However, we should remember not to execute the handlers until the promise has resolved.
- We will remove all handlers as soon as they finished executing.
Let's write the function to execute the handlers first:
First, we check if the promise has resolved already, if it hasn't we skip it. We only want to execute .then
and .catch
handlers only on resolved promises.
Then we run all of the handlers for .then
or .catch
depending on whether the promise is resolved or rejected. Afterward, we clean all of the handlers so that they don't get executed again.
Finally, we need to run the resolvers as soon as we set the result of the promise, which will execute the promise chain.
7) Implementing .then
We can run the handlers, but we still need a way to push the handlers to the queue. That's where .then
handler comes in:
The code looks daunting, but it is actually pretty simple when broken down.
By specification, .then
is supposed to be able to handle both fulfill and reject callbacks, and the types come directly from the ES6 Promise
interface. The handlers must always return a promise so that they can be chained, so as a result, we return a new Futurable
.
We push .then
and .catch
handlers, that are basically identical, except one is calling the resolve callback, and the other one - reject.
We resolve the promise with the result returned from the callback. In case the callback is not provided, we simply pass through the original value of the promise, which allows us to chain promises even if we omit either handler, essentially replacing it with (value) => value
.
Then we wrap everything in try/catch, because we should be able to reject the promise by throwing an error in the handler, as an alternative to calling reject
.
Finally, as mentioned earlier, we need to execute the resolvers after adding pushing the promise into the queue, so that if the promise was already resolved, the new handler would still run.
Let's test:
If you run this code, you should see both ok
and fail
, on the screen, which means that our Futurable
promise is chainable and can handle both fulfillment and rejection.
8) Implementing .catch and .finally
We already have a fully functional Promise now, but let's add some sugar: .catch
and finally
.
From the above test, you can see, that catch
is equivalent to then
without the onFulfill
handler:
And .finally
is as simple as executing a callback no matter if the promise was fulfilled or rejected:
The only caveat is that .finally
must not change the promise status, so it passes through whatever the result was - it returns the value if the promise was resolved or rethrows the error in case it was rejected.
You can see the full implementation here:
There we have it - the basic premise implementation we built ourselves. As a fun experiment, try to use it interchangeably with the native promise and see if it works. If you wish to refine the implementation further and deepen the understanding of promises, try to implement these static methods:
Promise.resolve
Promise.reject
Promise.race
Promise.any
Promise.all
Promise.allSettled
if you really want to go advanced.
Async/await
By now you might have gathered, that promises solve some of the issues of working with asynchronous operations in JavaScript, yet they're not perfect:
- They don't completely eliminate the nesting;
- Their readability is not great;
- While the promise can be returned synchronously, its result can't be accessed synchronously;
- Regular language constructs are not straightforward to use (e.g. try/catch block).
Well, recently a more convenient way of handling asynchronous operations was introduced - async/await, and promises are the heart of it.
Async functions
When declaring a function, we can mark it as async, which will allow us to return any value the same as always, but the result would automatically be considered a promise:
As we see in the example, returning a value is equivalent to resolving a promise. Even if there's no value returned, the result will still be a promise, except with undefined
as the result. Similarly, throwing an error is equivalent to rejecting the promise:
Await
What's cool about async functions, is that inside of them you can use the await
keyword to synchronously resolve promises:
The await
keyword makes the code blocking until the promise is resolved, essentially waiting for the operation to complete until it proceeds with code execution. This allows us to get results from asynchronous functions in much the same way as regular ones.
But that's not it. When you use await
the error will be thrown synchronously as well, so the regular try/catch block will work:
However, in the end, it's all promises, so async/await can be used with .then
, .catch
and .finally
interchangeably:
As a final exercise, let's rewrite our tea preparation function using async/await:
As you see async/await has eliminated excessive nesting and is generally simpler to read and understand.
Caveat
As you can imagine, async/await solves most of the remaining issues with promises. However, there are some common mistakes that beginners run into.
You can't use await
at the root level or regular functions, only in async functions:
Although, some modern JavaScript runtimes (modern browsers, latest node versions) allow async
at the root level. As a workaround for older JS runtimes, wrap the call in a self-invoking anonymous async function:
Final thoughts
Modern JavaScript provides ways to work with asynchronous operations conveniently. However, promises are the basis of everything that's async in JavaScript, so it's essential to understand them completely.
I've shown how you would implement promises yourself and hopefully, the process has shown you that there's no magic involved when you break them down.
If it was your first involvement with promises, you might have gotten a bit overwhelmed, but don't worry, with practice, using promises will become second nature, and you can always use this post as a reference if you want to revisit the implementation with fresh eyes after a while.
Let me know in the comments - what part of JavaScript would like a deep dive on next?