Deep Dive on Promises
Promises have been around since 2015 and are a powerful way to handle asynchronous code in Javascript. They allow you to write cleaner, more readable code by avoiding callback hell and making it easier to handle errors.
Newer methods are introduced periodically to improve the functionality of promises, and the async/await syntax has made working with promises even easier.
In this post, we'll take a deep dive into how promises work, their methods, and provide a reference for these methods.
We'll also look at the async/await syntax and how it can be used to simplify promise handling.
Basic promise support #
In Javascript promises represent the eventual completion (or failure) of an asynchronous operation and its resulting value. A promise is in one of three states:
- Pending: The initial state of a promise. The operation has not yet completed
- Fulfilled: The operation completed successfully and the promise has a resulting value
- Rejected: The operation failed and the promise has a reason for the failure
The initial status of a promise is pending
.
If the operation completes successfully, the promise is fulfilled
.
If the operation fails, the promise is rejected
.
Using the promise constructor #
We will first look at the low level ways to create promises. The promise constructor is a way to create a new promise. It takes a function as an argument, which is called the executor function. The executor function takes two arguments: resolve
and reject
. You call resolve
when the operation completes successfully and reject
when it fails.
function fetchWithRetries(
url: string,
retries: number = 3,
delayMs: number = 500): Promise<any> {
return new Promise((resolve, reject) => {
const attempt = (remainingRetries: number) => {
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
})
.then(data => resolve(data))
.catch(error => {
if (remainingRetries > 0) {
setTimeout(() => {
attempt(remainingRetries - 1)
}, delayMs)
} else {
reject(error)
}
})
}
attempt(retries)
})
}
Then we call the function with the URL we want to fetch, the number of retries, and the delay between retries.
The then
and catch
blocks are shorter than they were when we defined the function, all the functionality is encapsulated in the function definition.
fetchWithRetries('https://jsonplaceholder.typicode.com/posts/1', 3, 1000)
.then(data => console.log(data))
.catch(error => console.error('Fetch error after retries:', error))
Using Promise.withResolvers #
Promise.withResolvers
is a static method (currently at stage 4 of the TC39 process). It returns an object containing a new Promise object and two functions to resolve or reject it, corresponding to the two parameters passed to the executor of the Promise()
constructor.
function fetchWithRetries(url: string, retries = 3, delayMs = 500): Promise<any> {
const { promise, resolve, reject } = Promise.withResolvers<any>()
const attempt = (remainingRetries: number) => {
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
})
.then(resolve)
.catch(error => {
if (remainingRetries > 0) {
setTimeout(() => attempt(remainingRetries - 1), delayMs)
} else {
reject(error)
}
})
}
attempt(retries)
return promise
}
The fetchWithRetries
function now uses Promise.withResolvers
to create a promise and the corresponding resolve and reject functions. The rest of the code remains the same.
fetchWithRetries('https://jsonplaceholder.typicode.com/posts/1', 3, 1000)
.then(data => console.log('Fetched data:', data))
.catch(error => console.error('Failed after retries:', error))
Simplifying promise creation #
Rather than using the promise constructor we can use methods like then, catch, and finally to create promises. These methods are chainable and allow you to handle the result of the promise in a more readable way.
This is what I saw when I first started working with promises in the context of service workers.
In the example below, we do the following:
- Fetch JSON data from a URL
- Check if the response status is ok (or 200), if not we throw an error because something happened
- Convert the data to JSON
- Log the data to console
- If there is an error, we check the type of the returned error
- If it's an instance of
Error
, we log the error message - If it's not an instance of
Error
, we log the error as an unknown error
- If it's an instance of
- The
finally
block will run regardless.
We return unknown
as the type of the promise to force the user to narrow the type of the promise when used. This way we make sure we get the correct type of the object when we use it. We'll see more about this when we look at an exampe of using the promise.
function fetchJsonFromUrl(url: string): Promise<unknown> {
return fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then((data) => {
console.log('Received data:', data);
return data;
})
.catch((error) => {
if (error instanceof Error) {
console.error('Fetch error:', error.message);
} else {
console.error('Unknown error occurred:', error);
}
})
.finally(() => {
console.log("File fetched!");
});
}
Now that the promise is created, we can use it to fetch data from a URL. The promise will return the data as an object, which we can then use in our code.
const postUrl = 'https://jsonplaceholder.typicode.com/posts/1';
fetchJsonFromUrl<Post>(postUrl)
.then((post) => {
console.log('Post title:', post.title);
console.log('Post body:', post.body);
})
.catch((err) => {
// Error already logged inside fetchJsonFromUrl
// Optional extra handling here
console.log('An error occurred while fetching the post.');
})
.finally(() => {
console.log("Data fetching completed");
});
Promise methods #
There are many methods available on the Promise object that allow you to create and manipulate promises. They provide specialized functionality for different use cases. Here are some of the most commonly used methods
Promise.race #
The Promise.race()
static method takes an iterable of promises as input and returns a single promise.
The promise will fulfill with the status of the first promise that settles (either fulfilled or rejected), and its value will be the value or reason of that promise.
const promise1: Promise<string> = new Promise((resolve, reject) => {
setTimeout(resolve, 500, "one");
});
const promise2: Promise<string> = new Promise((resolve, reject) => {
setTimeout(resolve, 100, "two");
});
// Uses Promise.race to return the value of the first promise to resolve
Promise.race<string>([promise1, promise2])
.then((value: string) => {
console.log(value); // Result: "two"
})
.catch((error: Error) => {
console.error(error);
});
Promise.all #
Promise.all
takes an iterable of promises as input and returns a single Promise.
The returned promise fulfills when all of the input's promises fulfill with an array of the fulfillment values for each of the input's promise. It rejects when any of the input's promises rejects, with this first rejection reason.
// A resolved Promise with a number
const promise1: Promise<number> = Promise.resolve(3)
// A number value that can be treated as a resolved Promise in Promise.all
const promise2: number = 42
// A Promise that resolves to a string after 100ms
const promise3: Promise<string> = new Promise((resolve) => {
setTimeout(resolve, 100, 'foo')
})
// Use Promise.all to resolve all values and log the result
Promise.all([promise1, promise2, promise3]).then((values: [number, number, string]) => {
console.log(values)
// Expected output: Array [3, 42, "foo"]
})
Promise.allSettled #
Promise.allSettled
takes an iterable of promises as input and returns a single Promise.
The returned promise fulfills when all of the input's promises fulfill with an array of the fulfillment values for each of the input's promise, regardless of whether they fulfill or reject.
const promise1: Promise<number> = Promise.resolve(3);
const promise2: Promise<string> = new Promise((_, reject) =>
setTimeout(() => reject("foo"), 100)
);
const promises: Promise<unknown>[] = [promise1, promise2];
Promise.allSettled(promises).then((results: PromiseSettledResult<unknown>[]) => {
results.forEach((result) => {
console.log(result.status);
// Narrow the type using a type guard
if (result.status === "fulfilled") {
console.log("Value:", result.value);
} else {
console.log("Reason:", result.reason);
}
});
});
Promise.any #
Promise.any
takes an iterable of promises as input and returns a single Promise.
The returned promise fulfills when any of the input's promises fulfills, with this first fulfillment value.
It rejects when all of the input's promises reject with an AggregateError containing an array of all rejection reasons. Otherwise, promise.all will ignore rejections.
// Define each promise can resolve to
const promise1: Promise<never> = Promise.reject(0);
const promise2: Promise<string> = new
Promise((resolve) =>
setTimeout(resolve, 100, "quick")
);
const promise3: Promise<string> = new
Promise((resolve) =>
setTimeout(resolve, 500, "slow")
);
// Combine the promises into an array with the correct union type
const promises: Promise<string>[] = [promise1, promise2, promise3];
Promise.any(promises)
.then((value: string) => {
console.log(value);
})
.catch((error: AggregateError) => {
console.error("All promises were rejected:", error);
});
Promise.try #
Promise.try
takes a callback of any kind and wraps its result in a Promise.
Without promise.try
any asynchronous error the function throws may not bubble up to the caller and will not be the reason for rejection.
If the callbacks return a value, the returned promise will be resolved with that value.
If the callback throws an error, the returned promise will be rejected with that error.
In the example below, the function may throw an error, but we want it to be the reason the promise reject.
// A function that might throw an error
function mightThrow(): number {
if (Math.random() > 0.5) {
throw new Error('Something went wrong!');
}
return 42;
}
// Use Promise.try to safely wrap the function
Promise.try(() => mightThrow())
.then((result) => {
console.log('Success:', result)
})
.catch((error) => {
console.error('Caught error:', error.message)
})
Async/Await #
Async/await is a syntax introduced in ES2017 that allows you to write asynchronous code in a more synchronous style. It is built on top of promises and makes it easier to read and write asynchronous code.
export async function fetchJson(url: string): Promise<any> {
try {
const response = await fetch(url)
// Check if the response status is OK (status code in the range 200-299)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
console.log('Received data:', data)
return data
} catch (error) {
console.error('Failed to fetch JSON data:', error)
// Rethrow the error to be handled by the calling code if needed
throw error
}
}
Top level await #
await
is usually used to unwrap promises by passing a Promise as the expression. Using await pauses the execution of its surrounding async function until the promise is settled (that is, fulfilled or rejected). When execution resumes, the value of the await expression becomes that of the fulfilled promise.
If the promise is rejected, the await expression throws the rejected value. The function containing the await expression will appear in the stack trace of the error. Otherwise, if the rejected promise is not awaited or is immediately returned, the caller function will not appear in the stack trace.
Because await is only valid inside async functions and modules, which themselves are asynchronous and return promises, the await expression never blocks the main thread and only defers execution of code that actually depends on the result.
function resolveAfter2Seconds<T>(x: T): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x)
}, 2000)
})
}
async function f1(): Promise<void> {
const x = await resolveAfter2Seconds(10)
console.log(x) // 10
}
f1()
Notes #
Some notes from having reseached promises and working with them.
Difference between raw promises and Promise.any #
Early on I struggled with the difference between raw promises and Promise.any
. If they both return a single promise then what's the difference?
Raw promises work on a single promise, while Promise.any
works on multiple promises. The key difference is that Promise.any
will return the first successful result among multiple candidates, while raw promises will only resolve when the single promise resolves.
Feature | Raw Promise (promise.then) | Promise.any([...]) |
---|---|---|
Input | One promise | Multiple promises |
Resolves when | That one promise fulfills | The first of any promises fulfills |
Rejects when | That one promise rejects | All promises reject |
Error object | Standard Error | AggregateError |
Use case | Handle result of a single async operation | Use first successful result among multiple candidates |
Difference between Promise.all and Promise.allSettled #
Promise.all
will reject as soon as one of the promises in the iterable rejects, while Promise.allSettled
will wait for all promises to settle (either fulfilled or rejected) and return an array of objects describing the outcome of each promise.
Which one you use depends on your use case. If you want to stop as soon as one promise fails, use Promise.all
. If you want to wait for all promises to complete regardless of their outcome, use Promise.allSettled
.
This is different than using Promise.any
which will return the first promise that resolves.
When to use Async/Await #
Other than specific situations that may not have a full equivalent with Async/Await, async/await is more readable and easier to understand than raw promises.
Use async/await when:
- You want readable, synchronous-looking code
- You need try/catch for better error handling
- You're running sequential async steps
- You're looping through async tasks
Use raw Promises when:
- You need to fire things off in parallel using
Promise.all
orPromise.allSettled
- You're managing complex promise chains or combining with legacy code