Race Against Time - Handling Slow Operations with Promise.race in JavaScript
Published onGood things take time, but great apps know when to move on.
Sometimes, waiting too long isn’t a virtue - especially when it comes to web development. Imagine your app hanging indefinitely because some API is taking its sweet time. Annoying right? Well, let’s fix that.
If you’ve ever wished you could set a timer on operations, letting them run for a predetermined duration before gracefully throwing in the towel, Javascript’s Promise.race
should be your go to solution. At it’s core, it functions as a referee in a relay race: once one promise crosses the finish line - success or failure - the race is over. Spoiler alert, this is actually the core of the implementation we’ll be exploring. Essentially, we’ll be racing two promises.
In this short read, I’ll show you how to build a timeout mechanism for slow operations using Promise.race
. If you aren’t familiar with Promise.race
or are just curious, I’d recommend starting with my earlier guide fro a deeper dive into how it works and potential use cases. Otherwise, let’s place our bets and race long running operations and timeouts 😉.
The Concept
Before I show you the implementation of this behind door number 2, I’d like us to first (and just shortly), check out the concept behind door number 1. Here’s the idea: you have a long-running task, like fetching data from a remote API. What if you could give it a limit? Say, if it doesn’t finish within ten seconds, you can move on and handle the situation gracefully.
With this in mind, you simply “race” the task against a timeout promise The first promise to finish - whether it resolves or rejects - wins. If the timeout wins, you handle it like a boss and keep your app smooth and responsive.
Implementation
With that out of the way, let’s get our hands dirty with some code. Here’s how I would set up the timeout mechanism:
function performActionWithTimeout(action, timeoutMs, ...args) {
const actionPromise = action.apply(null, ...args);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject('operation timed out'), timeoutMs);
});
return Promise.race([actionPromise, timeoutPromise]);
}
The implementation above is simple, clean, and super effective. Of course, there are aspects you could improve such as being able to pass the execution context to the action, but that’s for a future post.
For now, this implementation just about serves our purpose to explore how we can timeout from long running tasks.
In the implementation, we have the performActionWithTimeout
function. It takes an asynchronous function as the first parameter and a timeout in milliseconds as the second parameter. Please note that for functions that don’t take any arguments, this is more than enough. However, if the function does take additional arguments, we capture all of them after the timeout parameter and will apply them to our function later on.
In the next line, we essentially invoke the asynchronous action passed in and it resolves with a Promise which we can later race against our timeout.
With the action initialized, we then set our Timeout Promise which would reject
after the specified number of milliseconds ellapse. Once done, we return the results of the Promise.race
call with the two promises in an array.
Example in Action
The implementation above is pretty handy, but I’d like to showcase how it can be used in realife.
async function longRunningTask() {
return new Promise((resolve, _) => {
setTimeout(() => resolve('resolved at 2 seconds'), 2000);
});
}
function main() {
performActionWithTimeout(longRunningTask, 1000)
.then((res) => console.log(res))
.catch((err) => {
console.error('Error: ', err);
}); // logs `Error: operation timeout`
}
In the implementation above, I initialize a longRunningTask
that takes 2 seconds to run. However, I set my timeout to 1 second
. As such, our task never resolves. If I instead set the timeout to 3 or 4 seconds, our longRunningTask
will resolve correctly.
Of course, I still needed to handle the rejection of the timeout error at the end. This approach is beneficial as it allows you to perform other actions at that point if you do check the error and determine it was a timeout error.
The long running task can be function you’d like by the way. To ellaborate, check out this api call example.
function longRunningApiCall(endpoint) {
return fetch(endpoint)
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.json();
})
.then((data) => data);
}
function main() {
performActionWithTimeout(longRunningApiCall, 5000, 'https://api.example.com/data');
}
In this example, if the fetch takes longer than 5 seconds, the timeout promise will reject, triggering the .catch()
block. No more endless loading spinners!
Why This Matters
This simple trick packs a punch. It’s not just about avoiding app freezes—it’s about giving users a smooth experience and making your app feel responsive and reliable.
Here are a few ways you can build on this:
- Automatic retries: Retry failed requests with backoff to give it another shot.
- Fallbacks: Switch to cached or alternative data sources when things go south.
- Diagnostics: Log slow operations to pinpoint performance bottlenecks.
With just one feature—Promise.race
—you can create tools that make your app more resilient and user-friendly.
What’s Next?
Timeouts are just the start. Next time, we’ll explore using Promise.race
to fetch the quickest response from redundant sources. Think of it as setting up a backup plan where the fastest server wins. Sound cool? Make sure to subscribe so you don’t miss that and other tips I share for building smarter apps.
Speaking of which—how are you using or planning to use Promise.race
in your projects? Drop your thoughts in the comments or send me a message. I’d love to hear from you!
Until next time, keep coding and keep it snappy.