Fetch Requests Hanging Indefinitely When the Server Never Responds
You fire a fetch() call, the network tab shows the request sitting in a pending state, and nothing ever comes back β no error, no timeout, just silence. This happens more often than you'd think: a server that accepts the TCP connection but never sends a response will keep your request dangling forever because fetch has no built-in timeout mechanism.
The good news is that fixing it is straightforward once you understand what's going on. This article walks you through the mechanics, the right APIs to use, and patterns you can copy straight into production code.
What You'll Learn
- Why
fetchhangs indefinitely and what the browser is actually doing while it waits - How to cancel a request after a deadline using
AbortController - The modern
AbortSignal.timeout()shortcut available in current browsers and Node 18+ - How to build a reusable
fetchWithTimeoutwrapper with retry logic - Common mistakes that silently break your timeout logic
Prerequisites
You need a working knowledge of the fetch API and JavaScript Promises. The code examples target modern browsers (Chrome 99+, Firefox 100+, Safari 16+) and Node.js 18+. No external libraries are required.
What Actually Happens When a Server Goes Silent
When you call fetch(url), the browser opens a TCP connection to the server and sends an HTTP request. Under normal circumstances the server replies with response headers promptly and the Promise resolves. When a server is overloaded, misconfigured, or actively hanging β it may accept the TCP connection but never write a single byte back.
From the browser's perspective the connection is open and alive. TCP keepalives prevent the OS from closing it automatically for a very long time, sometimes tens of minutes depending on operating-system defaults. The browser has no way to distinguish "server is thinking" from "server is stuck", so it just keeps waiting. The fetch Promise remains pending, consuming memory and a connection slot, until the browser's own internal limit (which varies by browser and can be several minutes) finally closes it or the tab is navigated away.
This is different from a DNS failure or a refused connection, both of which throw a TypeError quickly. A silent server is the hardest case because nothing goes wrong at the transport layer β everything looks fine until it isn't.
Why fetch Has No Built-In Timeout
The Fetch specification deliberately does not include a timeout option. The reasoning from the standards bodies was that timeout semantics are complex: what does it mean to "time out" a streaming response that's halfway through? Should the clock start at request send, first byte received, or last byte received?
Instead, the specification provides AbortController and AbortSignal as a general cancellation primitive. Your code decides when to cancel; the browser handles the mechanics. This is more flexible than a single timeout number, but it means you have to wire it up yourself.
Implementing a Timeout with AbortController
AbortController gives you a signal object you pass to fetch. When you call controller.abort() on the controller, the request is cancelled and the Promise rejects with a DOMException whose name is "AbortError".
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`);
}
throw err; // re-throw network errors, CORS failures, etc.
} finally {
clearTimeout(timerId); // always clean up the timer
}
}
The finally block is critical. If the request succeeds before the timeout fires, you must clear the timer. If you don't, the setTimeout callback will eventually fire and call controller.abort() on a request that already resolved β which is harmless for this particular controller but is a memory leak pattern that compounds badly in long-running apps.
Notice that we re-throw errors that are not AbortError. A CORS error appearing only in production would surface here as a TypeError with a different message, and you want those to propagate normally rather than getting swallowed.
Using AbortSignal.timeout() (The Modern Shortcut)
If you only need a simple deadline and don't need to cancel the request for any other reason, AbortSignal.timeout() is the cleanest solution. It creates a signal that automatically aborts after the specified number of milliseconds with no extra cleanup needed on your part.
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(8000),
});
const data = await response.json();
return data;
} catch (err) {
if (err.name === 'TimeoutError') {
// AbortSignal.timeout() rejects with TimeoutError, not AbortError
console.error('Request exceeded 8 second deadline');
} else if (err.name === 'AbortError') {
// A different abort source triggered (e.g., user navigation)
console.error('Request was aborted externally');
} else {
throw err;
}
}
There's a subtle but important difference: AbortSignal.timeout() rejects with a TimeoutError, while manually calling controller.abort() rejects with an AbortError. If you mix both patterns (for example, combining a timeout signal with a user-triggered cancel), you need to handle both error names. Use AbortSignal.any([signal1, signal2]) in browsers that support it to merge multiple signals cleanly.
Wrapping fetch in a Reusable fetchWithTimeout Utility
In a real app you'll want a single place to set your timeout policy rather than scattering AbortSignal.timeout() calls everywhere. Here's a utility that merges an external signal from the caller (for user-initiated cancels) with an internal timeout signal:
/**
* fetch with a mandatory timeout and optional external abort signal.
* @param {string} url
* @param {RequestInit & { timeoutMs?: number }} options
*/
async function apiFetch(url, { timeoutMs = 10000, signal: externalSignal, ...rest } = {}) {
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal);
// AbortSignal.any() aborts when the FIRST signal aborts
const combinedSignal = AbortSignal.any(signals);
const response = await fetch(url, { ...rest, signal: combinedSignal });
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText} β ${url}`);
}
return response;
}
// Usage
try {
const res = await apiFetch('/api/data', { timeoutMs: 5000 });
const json = await res.json();
} catch (err) {
if (err.name === 'TimeoutError') {
// show user a "server is slow" message
} else if (err.name === 'AbortError') {
// user navigated away β silently ignore
} else {
// real error β log it
console.error(err);
}
}
This pattern keeps all timeout logic in one place, makes the default configurable per environment, and composes cleanly with React's useEffect cleanup or any other cancellation source you already have.
Adding Retry Logic for Transient Failures
A timeout on its own tells you the request failed. Retrying automatically handles the cases where the server was briefly overloaded and would succeed on a second attempt. The key rule: only retry on timeouts and 5xx server errors, never on 4xx client errors (those won't fix themselves).
async function fetchWithRetry(url, options = {}, { retries = 3, backoffMs = 300 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await apiFetch(url, options);
} catch (err) {
const isTimeout = err.name === 'TimeoutError';
const isServerError = err.message.startsWith('HTTP 5');
const hasAttemptsLeft = attempt < retries;
if ((isTimeout || isServerError) && hasAttemptsLeft) {
const delay = backoffMs * 2 ** (attempt - 1); // exponential backoff
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err; // not retryable, or out of retries
}
}
}
Exponential backoff (300ms, 600ms, 1200ms...) is important. Retrying instantly under load just makes the server's problem worse. For production systems, add a small random jitter: delay + Math.random() * 100. This prevents a fleet of clients from all retrying in lock-step after a shared outage.
Common Pitfalls and Gotchas
Timeout covers only time-to-first-byte, not download time
If you stream a large file, the timeout clock starts at request send. Once the server starts responding (first byte arrives), the AbortSignal.timeout() timer does not reset. If your download takes longer than the timeout, it will be aborted mid-stream. For large downloads, either set a generous timeout or don't use a single deadline at all β instead, track the time between received chunks manually and abort if a chunk stalls.
Forgetting to handle the Response body timeout separately
The fetch Promise resolves as soon as response headers arrive. If you then call response.json() and the server streams the body slowly (or stops mid-body), that can hang too. The same AbortSignal passed to fetch covers body reading as well, so as long as you use the same signal, you're covered. Just make sure your timeout is generous enough to include body download time for normal payloads.
Reusing an already-aborted signal
Once an AbortController has been aborted, its signal.aborted property is permanently true. Passing that signal to a subsequent fetch call will cause the new request to abort immediately before it even leaves the browser. Always create a fresh AbortController (or call AbortSignal.timeout() again) for each new request.
Not cleaning up timers in React effects
In React, if you trigger a fetch inside useEffect and the component unmounts before the request completes, you want to cancel the request to avoid updating state on an unmounted component. Pass the controller.signal to fetch, and call controller.abort() in the effect's cleanup function. Catch the resulting AbortError and ignore it silently β it's expected.
Debugging Hanging Requests in DevTools
When a request hangs, open the Network tab in Chrome DevTools. Requests stuck in the Pending state with no response size or timing data usually mean the server accepted the connection but sent nothing back. Hover the timing bar to see which phase the request is stuck in: Stalled means it hasn't started sending yet (connection limit hit), while Waiting (TTFB) means the request was sent but the server hasn't replied.
If you see Waiting (TTFB) climbing indefinitely, that confirms a silent server. At that point you can manually test with curl --max-time 5 https://your-api.com/endpoint from a terminal to verify the issue is server-side and not a browser-specific problem. You might also be dealing with a production-only difference, similar to how CORS errors sometimes only surface in deployed environments rather than local development.
For Node.js environments, the same AbortController pattern applies to the native fetch available since Node 18. In older Node versions using node-fetch or axios, consult those libraries' own timeout options β but the concepts are identical. Understanding how JavaScript handles objects under the hood, such as the silent failures in structuredClone, can also help when debugging why certain request configurations don't serialize as expected.
Next Steps
You now have all the pieces to prevent fetch requests from hanging your application. Here's what to do next:
- Audit your existing fetch calls. Search your codebase for bare
fetch(calls with nosignaloption and prioritize wrapping the ones that hit external or slow services first. - Pick a default timeout that fits your SLAs. A common starting point is 10 seconds for user-facing requests and 30 seconds for background data sync. Adjust based on your actual p99 response times.
- Add retry logic only where it's safe. Retrying is safe for idempotent operations (GET, most PUT). Be careful with POST β a duplicate request may create duplicate records if your server isn't idempotent.
- Log timeout errors to your error tracker. A spike in timeout errors is an early warning of server degradation. Treat timeouts as signals, not just user annoyances.
- Write a test for the timeout path. Mock a server that never responds using a long-lived Promise in your test suite and assert that your utility throws a
TimeoutErrorwithin the expected window.
Frequently Asked Questions
How do I set a timeout on a fetch request in JavaScript?
JavaScript's fetch API has no built-in timeout option, so you need to use AbortController or AbortSignal.timeout(milliseconds) and pass the resulting signal to your fetch call. If the request doesn't complete within the deadline, the Promise rejects with a TimeoutError or AbortError depending on which API you used.
Why does my fetch request stay in 'pending' forever without throwing an error?
This happens when the server accepts the TCP connection but never sends any response bytes back. The browser has no way to distinguish a slow server from a permanently stalled one, so the fetch Promise stays pending until the browser's own internal limit is hit, which can be several minutes. Adding an AbortSignal.timeout() to your fetch call will cancel it after a defined deadline instead.
What is the difference between AbortError and TimeoutError when cancelling a fetch?
AbortError is thrown when you manually call controller.abort() on an AbortController. TimeoutError is thrown specifically when an AbortSignal created with AbortSignal.timeout() expires. If you mix both patterns, you need to catch and handle both error names separately in your catch block.
Should I retry a fetch request automatically after a timeout?
Yes, but only for idempotent requests like GET or PUT, and only after waiting a short backoff delay to avoid hammering an already-struggling server. Never auto-retry POST requests unless your server explicitly guarantees idempotency, because duplicate requests may create duplicate records.
Does the AbortSignal timeout include the time to download the response body?
Yes, the single timeout deadline covers the entire lifecycle of the fetch call from request send through body download. If a large response body takes longer to download than your timeout allows, the request will be aborted mid-stream. For large file downloads, set a more generous timeout or track per-chunk stall time manually instead.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!