Article summary
Even though Node.js has great out-of-the-box support for asynchronous programming and promises with its async/await
syntax, it doesn’t include the ability to add a timeout to these promises. I had to deal with this on a recent React Native project while integrating with a third-party library. This library had a number of functions that were asynchronous, but they were prone to never resolving and, instead, hanging forever.
After some experimenting, I was able to create a method to add timeouts to promises using only built-in Node functionality. I’ll walk you through how to implement this in your own code, with some useful improvements I made along the way.
As a note, all of the examples in this post will be in TypeScript, but this will work in plain JavaScript as well.
Creating a Timeout
We’re going to implement a timeout for our promise by actually creating another promise. The basic idea is that we’ll create a new promise that will reject after the given amount of time, and then we’ll race the two promises to see which one finishes first.
The Node Promise API actually has a built-in function for this called Promise.race
. It takes in a list of promises and returns the result of the first promise to resolve or reject. Using that function, we can create a basic timeout that looks something like this:
const promiseWithTimeout = (timeoutMs: number, promise: () => Promise<any>) => {
return Promise.race([
promise(),
new Promise((resolve, reject) => setTimeout(() => reject(), timeoutMs)),
]);
}
In the function above, we’re creating a new promise that uses the built-in setTimeout
function to reject after the given amount of milliseconds. We’re then racing that with our original promise to see which one finishes first. This is a fine solution that will work, but there are still some improvements we can make.
Clearing the Timeout
One thing we can do to make this a bit cleaner is clear the timeout that we create, in case our original promise finishes before the time expires. This isn’t strictly necessary — since the promise returned by Promise.race
will resolve when our original promise resolves, there isn’t any harm in the timeout promise rejecting some time after that. As long as you don’t have any stateful code in your timeout promise, it won’t affect what the original promise resolved to. Nevertheless, it’s good practice to be explicit about clearing a timeout that you set.
In order to clear the timeout, we’ll have to store the timeout handle when we create it. Since we only want to clear the timeout when the original promise resolves, we’ll then clear that handle by calling then
on the promise that Promise.race
returns.
Here’s our example from before, but with code to store and then clear the timeout:
const promiseWithTimeout = (timeoutMs: number, promise: () => Promise<any>) => {
let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise((resolve, reject) => {
timeoutHandle = setTimeout(() => reject(), timeoutMs);
};
return Promise.race([
promise(),
timeoutPromise,
]).then((result) => {
clearTimeout(timeoutHandle);
return result;
});
}
We’ve created a variable to store our timeout handle and then made sure we clear that when our original promise resolves. It’s a simple solution that keeps our timeout from firing if our original promise resolves before the time has expired.
Additionally, you can also add a catch
call after the then
block to handle anything that needs to happen if our promise does timeout. This is useful if you want to log something or reset some part of your state.
Adding a Rejection Message
Another useful improvement we can make to our timeout is to allow a developer to specify a nice, human-readable message that will be displayed if our promise does not finish before the specified time. All we have to do is make our function take in a message and then include an error with that message when we reject our timeout promise.
It would look something like this:
const promiseWithTimeout = (timeoutMs: number, promise: () => Promise<any>, failureMessage?: string) => {
let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise((resolve, reject) => {
timeoutHandle = setTimeout(() => reject(new Error(failureMessage), timeoutMs);
};
return Promise.race([
promise(),
timeoutPromise,
]).then((result) => {
clearTimeout(timeoutHandle);
return result;
});
}
Our function now allows an optional error message to be passed in. This lets us present something helpful to the user that can give them further instructions or tell them to try again.
I chose to have our timeout promise call reject
rather than resolve
because I want it to be clear that an error occurred and that whichever process set this timeout should handle the error case. You could instead just make both your original promise and your timeout promise resolve and therefore not need a rejection message, but I don’t recommend this.
Adding Types
Finally, we can add good types so any consumer of this function will know exactly what they’re getting back. Right now, our function has a return type of Promise<any>
. We want our return type to mach the return type of the original promise that gets passed in. Since our timeout promise never returns (it just throws an error), we can know what type our return value will be.
Here’s some additional code that will help us do that:
const promiseWithTimeout = <T>(timeoutMs: number, promise: () => Promise<T>, failureMessage?: string) => {
let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((resolve, reject) => {
timeoutHandle = setTimeout(() => reject(new Error(failureMessage), timeoutMs);
};
return Promise.race([
promise(),
timeoutPromise,
]).then((result) => {
clearTimeout(timeoutHandle);
return result;
});
}
In this snippet, we added a generic type T
that gets inferred from the return value of the promise that we pass in. But that alone isn’t good enough. Our Promise.race
will now have a return type of Promise<unknown>
since it can’t infer the return type of our timeout promise. To fix that, we need to be explicit that our timeout promise will never return. We do this by adding a type parameter of never
when we create our timeout promise.
With these additions in place, we now have promises that successfully timeout after a given time. They clear any timers they set, can take a helpful error message, and have good types.
Let me know if there are any other things you’ve done to improve promises in your codebase.