Learning JavaScript can feel like driving on a bumpy road. One of the biggest hurdles is understanding Promises, which requires knowing how JavaScript works and its limits.
This can be frustrating because Promises are essential today. They are the main way to handle tasks that happen at the same time, and most new web features depend on them. Without knowing Promises, it's difficult to work well with JavaScript.
In this tutorial, we'll start from the basics to explain Promises. I'll share important tips that took me a long time to learn. By the end, you'll have a more in-depth understanding of Promises and how to use them well.
What problem the Promises solve
The synchronous JavaScript
Before learning about Javascript promises, it’s essential to understand what problem it solves. Firstly, it’s essential to remind that Javascript is a single threaded* language. Javascript only has one thread, and so it can only do one thing at a time. It can’t multitask.
Javascript being single threaded has some function that occupy the main thread for an extended period of time. For example, window.alert()
. This function is used to display an alert to the user.
Click on the button in this playground, and try to interact with the page while the alert box is open:
Look at how the page is completely unresponsive?
You can’t do anything like scroll, click any links or select any text! The Javascript is busy waiting for us to close the alert box so that it can finish running that code. While it’s waiting, it can’t do anything else, and so the browser locks down the UI.
Callbacks
That first tool that comes in my mind for solving these type of problems is setTimeout
.
setTimeout is a function which accepts two arguments:
- A “work” to do, at some point in the future.
- The amount of time to wait for.
An example:
console.log('Begin');
setTimeout(() => {
console.log('wait for 1 second');
}, 1000);
The chunk of work is passed in through a function. This pattern is called a callback.
setTimeout
is known as an asynchronous function. This means that it doesn’t block the thread. By contrast, window.alert()
is synchronous because the Javascript thread can’t do anything else while it’s waiting.
One downside with asynchronous code is that it means our code won’t always run in a linear order. Take a look at the following code:
console.log('1. Before setTimeout');
setTimeout(() => {
console.log('2. Inside setTimeout');
}, 1000);
console.log('3. After setTimeout');
You might expect these logs to fire in order from top to bottom: 1
> 2
> 3
. But remember, the whole idea with callbacks is that we’re scheduling a call back. The JavaScript thread doesn’t sit around and wait, it keeps running.
After the running, the logs will look like this:
00:000
: Log "1. Before setTimeout".00:001
: Register a timeout.00:002
: Log "3. After setTimeout".00:1001
: Log "2. Inside setTimeout".
setTimeout()
registers the callback, like scheduling a meeting on a calendar. It only takes a tiny fraction of a second to register the callback, and once that’s done, it moves right along, executing the rest of the program.
But if we want to set up a 3-second countdown, how do we do it?
Before promises, the most common set up was nested callbacks, something like this:
console.log("3…");
setTimeout(() => {
console.log("2…");
setTimeout(() => {
console.log("1…");
setTimeout(() => {
console.log("Happy New Year 🎉!!");
}, 1000);
}, 1000);
}, 1000);
This is just brutal for the eyes, This pattern is called Callback Hell 🔥.
Promises was developed to solve some problems of Callback Hell.
What is a JavaScript Promise
JavaScript promises are a way to handle operations that take time, like data fetching, without blocking the rest of your code execution. Think of them as a promise to return a result or an error when the operation completes.
How to use Promises
The fetch()
function allows us to make network requests, typically to retrieve some data from the server.
Consider this code:
const someData = fetch('/api/get-data');
console.log(someData);
// -> Promise {<pending>}
When we call fetch()
, it starts the network request. This is an asynchronous operation, and so the JavaScript thread doesn't stop and wait. The code keeps on running.
But then, what does the fetch()
function actually produce? It can’t be the actual data from the server, since we just started the request and it’ll be a while until it’s resolved. Instead, it’s a sort of note from the browser that says, “Hey, I don’t have your data yet, but I promise I'll have it soon!”.
More concretely, Promises are JavaScript objects. Internally, Promises are in one of three states:
pending
— the work is in-progress, and hasn't yet completed.fulfilled
— the work has successfully completed.rejected
— something has gone wrong, and the Promise could not be fulfilled.
While a Promise is in the pending
state, it’s said to be unresolved. When it finishes its work, it becomes resolved. This is true whether the promise was fulfilled or rejected.
Typically, we want to register some sort of work to happen when the Promise has been fulfilled. We can do this using the .then()
method:
fetch('/api/get-data').then((response) => {
console.log(response);
// Response { type: 'basic', status: 200, ...}
});
fetch()
produces a Promise, and we call .then()
to attach a callback. When the browser receives a response, this callback will be called, and the response object will be passed through.
When you use the Fetch API to get data, you often need an extra step to convert the response into JSON data:
fetch('/api/get-data')
.then((response) => response.json())
.then((json) => console.log(json));
The
response.json()
creates a new Promise that is fulfilled once all data is available in JSON format.But, why is
response.json()
asynchronous? Didn't we already wait for the response?Not always. Servers can send data in portions, like streaming a video, which means data comes in parts. Fetch resolves its Promise when the first byte arrives, but
response.json()
waits until the whole data arrives. Usually, JSON isn’t sent in chunks, so both Promises resolve together, but this process is built to handle streamed data just in case.Creating your own Promises
What if the API we want to work with doesn’t support Promises?
For example, setTimeout was created before Promises existed. If we cant to avoid Callback Hell when working with timeouts, we’ll need to create our own Promises.
Here what the syntax look like:
const myOwnPromise = new Promise((resolve) => {
// Do some sort of asynchronous work, and then
// call `resolve()` to fulfill the Promise.
});
myOwnPromise.then(() => {
// This callback will be called when
// the Promise is fulfilled!
})
Promises are versatile and don't perform any actions by themselves. When we instantiate a Promise with new Promise()
, we provide a function that outlines the specific asynchronous task we want to execute. This task could be anything, such as making a network request, setting a timeout, or other similar operations.
After completing the task, we use resolve()
to let the Promise know that it was successful and finalize it.
If we want to avoid a callback hell and want to recreate our timeout with promise, we could have something like this:
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(
() => resolve(),
duration
);
});
}
JavaScript treats functions as "first-class citizens", allowing them to be used just like regular data types such as strings and numbers.
Note that our
resolve()
method doesn’t have anything in parameter, means that the promise returns nothing. For this example, it just serves a demo to apply a delay before running our instructions. But in general or in most common use cases, the resolve method returns some data.Another important thing to understand about Promises is that they can only be resolved once. Once a Promise has been fulfilled or rejected, it stays that way forever.
While we can’t trigger the same sleep
Promise, we can chain multiple Promises together:
sleep(1000)
.then(() => {
console.log('2');
return sleep(1000);
})
.then(() => {
console.log('1');
return sleep(1000);
})
.then(() => {
console.log('Happy New Year 🎉!!');
});
When our original Promise is fulfilled, the .then()
callback is called. It creates and returns a new Promise, and the process repeats.
Resolving Promises
Up to now, we've utilized the resolve function without any arguments, merely to indicate the completion of asynchronous tasks. However, there are instances where we need to pass along data.
Here's a sample scenario using a fictional database library that employs callbacks.
function getUser(userId) {
return new Promise((resolve) => {
// The asynchronous work, in this case, is
// looking up a user from their ID
db.get({ id: userId }, (user) => {
// Now that we have the full user object,
// we can pass it in here...
resolve(user);
});
});
}
getUser('a6f9bc43-fd59-4e74-99d3-7b3a30361d92').then((user) => {
// ...and pluck it out here!
console.log(user);
// { name: 'Kevin', ... }
})
Rejected Promises
Sadly, in JavaScript, Promises don't always turn out as expected, and occasionally, they fail.
Take the Fetch API, for instance; it doesn't assure that our network requests will go through successfully. The reason could be an unstable internet connection or a server issue. In such cases, the Promise won't be completed properly.
To manage this, we can use the .catch()
method:
fetch('/api/get-data')
.then((response) => {
// ...
})
.catch((error) => {
console.error(error);
});
When a Promise is fulfilled, the .then()
method is called. When it is rejected, .catch()
is called instead. We can think of it like two separate paths, chosen based on the Promise’s state.
So, let’s say the server sends back an error. Maybe a 404 Not Found, or a 500 Internal Server Error. That would cause the Promise to be rejected, right?
Surprisingly no. In that case, the Promise would still be fulfilled, and the
Response
object would have information about the error:Response { ok: false, status: 404, statusMessage: 'Not Found'}
This can be a bit surprising, but it does sorta make sense: we were able to fulfill our Promise and receive a response from the server. We may not have gotten the response we wanted, but we still got a response.
When it comes to custom Promises, we can reject them using a 2nd callback parameter, reject
:
function changeInUpperCase(word) {
return new Promise((resolve, reject) => {
if (word.length > 5) {
reject(new Error(JSON.stringify({
status: 400,
code: 'TOO_MUCH_TEXT',
message: 'word is too long',
data: null
})
));
return;
}
resolve(word.toUpperCase());
})
}
If we encounter issues within our Promise, we can use the reject()
function to indicate that the promise didn't resolve. The arguments we provide, usually an error, will be forwarded to the .catch()
handler.
As we saw earlier, promises are always in one of three possible states:
pending
, fulfilled
, and rejected
. Whether a Promise is “resolved” or not is a separate thing. So, shouldn't my parameters be named “fulfill” and “reject”?Here’s the deal: the
resolve()
function will usually mark the promise as fulfilled
, but that's not an iron-clad guarantee. If we resolve our promise with another promise, things get pretty funky. Our original Promise gets “locked on” to this subsequent Promise. Even though our original Promise is still in a pending
state, it is said to be resolved at this point, since the JavaScript thread has moved onto the next Promise.Async / Await
When we mark async
keyword on a function, we guarantee that it returns a Promise, even if the function doesn’t do any sort of asynchronous work. Similarly, the await
keyword is syntactic sugar for the .then()
callback:
// This code...
async function pingHttpRequest(endpointURL) {
const response = await fetch(endpointURL);
return response.status;
}
// ...is equivalent to this:
function pingHttpRequest(endpointURL) {
return fetch(endpointURL)
.then((response) => {
return response.status;
});
}
Promises provide JavaScript with the necessary structure to make the code appear synchronous, even though it works asynchronously in the background.
It's really quite amazing ✨.
Drawbacks coming with promises
From my experience, these are some drawbacks coming with promises.
Complex Error Handling
fetch('/api/data')
.then(response => response.json())
.then(data => {
// Process data
})
.catch(error => {
console.error('Error fetching data:', error);
});
In a simple promise chain like the one above, error handling is straightforward. However, when chains become complex, handling errors at every point can be cumbersome and prone to mistakes, leading to unhandled errors if not managed carefully.
Readability Issues
fetch('/api/data')
.then(response => response.json())
.then(data => fetch(`/api/more-data/${data.id}`))
.then(moreDataResponse => moreDataResponse.json())
.then(moreData => console.log(moreData))
.catch(error => console.error('Error:', error));
As chains grow with multiple .then()
calls, the code can become difficult to read and maintain. This becomes particularly challenging if there are many conditional branches or interdependencies between asynchronous operations.
Lack of Direct Cancellation
const fetchData = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve('Data fetched');
}, 5000);
});
// No direct way to cancel this promise
Once a promise is initiated, like the one above using setTimeout
, there is no straightforward way to cancel it. In contrast, other asynchronous patterns might offer cancellation features, like aborting an HTTP request or stopping a timer directly, which can be more efficient and resource-friendly.
These drawbacks make it essential to use modern practices like async/await
and libraries that can handle promise cancellation to mitigate these challenges.
I hope you learned something new, and you now have a good understanding of the JavaScript Promises!
🔍. Similar posts
Simplifying Layouts With React Fragments
18 Jan 2025
Stop Installing Node Modules on Your Docker Host - Install Them in the Container Instead
14 Jan 2025
An Easy to Understand React Component Introduction for Beginners
14 Jan 2025