We’ve all been there: staring at a loading spinner that never seems to stop.
In web development, a common reason for such frustration is a network request that takes too long or, worse, never responds.
While fetch is a powerful API for making network requests, by default, it will wait indefinitely for a response.
This isn’t ideal for user experience or application stability. What if you could tell your fetch request, “Hey, if you don’t hear back within X milliseconds, just give up”?
Good news! You can, and it’s surprisingly elegant using a combination of the fetch API and Promise.race. Let’s dive in.
The Problem: Indefinite Waits
Imagine you’re fetching user data:
fetch('https://api.example.com/user/123')
.then(response => response.json())
.then(data => console.log('User data:', data))
.catch(error => console.error('Error fetching user data:', error));
This code works fine for successful and immediately failing requests.
But what if api.example.com is having a bad day and just… hangs?
Your user’s loading spinner will spin forever, and your application might appear frozen.
The Solution: Racing Your Fetch Against a Timeout Promise
The core idea is simple: we’ll create two promises and race them against each other using Promise.race().
- Your Actual
fetchPromise: This is the promise returned by yourfetch(url)call. - A Timeout Promise: This is a custom promise that will reject after a specified delay.
Whichever promise settles first (either the fetch succeeding/failing, or the timeout kicking in) will determine the outcome of our combined operation.
Let’s build the fetchWithTimeout utility function:
/**
* Fetches a resource from the network with a specified timeout.
* If the fetch request does not resolve within the given duration,
* the returned Promise will reject with a timeout error.
*
* @param {string} url - The URL of the resource to fetch.
* @param {number} timeoutMs - The maximum time (in milliseconds) to wait for the request.
* @returns {Promise<Response>} A Promise that resolves with the Fetch Response
* or rejects with an Error if it times out or fails.
*/
function fetchWithTimeout(url, timeoutMs) {
// 1. The actual fetch request, which returns a Promise
const fetchPromise = fetch(url);
// 2. A Promise that will reject after 'timeoutMs' milliseconds
const timeoutPromise = new Promise((_, reject) => {
// We only care about rejection for this promise, so 'resolve' is ignored (conventionally named '_')
setTimeout(() => {
reject(new Error('Request timed out!')); // Reject with a specific timeout error
}, timeoutMs);
});
// 3. Race the fetch promise against the timeout promise
// Whichever settles first (resolves or rejects) determines the outcome.
return Promise.race([fetchPromise, timeoutPromise]);
}
How It Works Under the Hood
Let’s trace the execution:
const fetchPromise = fetch(url);: As soon asfetchWithTimeoutis called, the network request tourlis initiated.fetchPromiseis now a pending Promise waiting for the server’s response.const timeoutPromise = new Promise(...): Simultaneously, we create a second, independent Promise. Inside this promise, asetTimeoutis scheduled. AftertimeoutMsmilliseconds, if thatsetTimeoutfires, it callsreject(new Error('Request timed out!')), causingtimeoutPromiseto become rejected.return Promise.race([fetchPromise, timeoutPromise]);: This is where the magic happens.Promise.racecreates a new “master” promise.- Scenario A: fetchPromise wins the race (request completes in time).If the fetchPromise resolves (e.g., the server responds quickly with a 200 OK) or rejects (e.g., a network error occurs) before timeoutMs passes, then Promise.race immediately adopts that outcome. The setTimeout that was scheduled for timeoutPromise will still fire later, but its reject call will have no effect on the already-settled Promise.race result.
- Scenario B: timeoutPromise wins the race (request takes too long).If timeoutMs passes before fetchPromise settles, then timeoutPromise rejects first. Promise.race immediately adopts this rejection, and your .catch() block will receive the “Request timed out!” error. The original fetchPromise might still be pending and silently complete later in the background, but your application will have moved on.
Putting fetchWithTimeout into Practice
Here’s how you’d use this utility function in your application:
// Example Usage: Fetching data with a 500ms timeout
fetchWithTimeout('https://api.example.com/data', 500)
.then(response => {
// Check if the network response was OK (status 200-299)
if (!response.ok) {
// If response is not OK, throw an error to trigger the .catch block
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json(); // Parse the JSON body. This also returns a Promise.
})
.then(data => {
console.log('Data successfully fetched within timeout:', data);
// Render your data here
})
.catch(error => {
// This block catches:
// 1. "Request timed out!" errors
// 2. Network errors during the fetch itself
// 3. HTTP errors (e.g., 404, 500) from the .then(response => ...) block
// 4. JSON parsing errors
console.error('An error occurred during fetch:', error.message);
// You can check the error message to distinguish timeout
if (error.message === 'Request timed out!') {
console.log('Please check your internet connection or try again later.');
}
});
// Example of a slow request (simulated)
// This URL will likely take longer than 50ms to respond, triggering the timeout
fetchWithTimeout('https://jsonplaceholder.typicode.com/posts/1', 50)
.then(response => response.json())
.then(data => console.log('Slow data fetched (should not happen):', data))
.catch(error => console.error('Expected timeout error for slow request:', error.message));
// Example of a fast request (this should succeed)
fetchWithTimeout('https://jsonplaceholder.typicode.com/todos/1', 2000) // Generous timeout
.then(response => response.json())
.then(data => console.log('Fast request succeeded:', data))
.catch(error => console.error('Fast request error:', error.message));
Important Consideration: True Aborting (AbortController)
While our fetchWithTimeout function effectively stops your code from waiting for a slow request, it’s crucial to understand that it does not inherently abort the underlying network request itself in the browser.
The browser might still continue to download data in the background, consuming bandwidth and resources, even if your application has moved on.
For true, resource-saving cancellation of fetch requests, you would use the AbortController API.
This is a more advanced topic but is the gold standard for robust cancellation.
For many simpler scenarios, however, the Promise.race pattern provides a perfectly acceptable and easy-to-implement timeout solution.
Conclusion
Implementing fetch timeouts with Promise.race is a powerful technique to enhance the reliability and user experience of your web applications.
By gracefully handling unresponsive servers, you prevent frustrating hangs and provide immediate feedback to your users.
Add this pattern to your asynchronous JavaScript toolkit, and keep those loading spinners turning only when they truly need to!



