Introduction
Node.js 18+ includes the global fetch() API, which supports request cancellation via AbortSignal. When a fetch request takes too long, an AbortSignal.timeout() should cancel the request and throw an AbortError. However, the cancellation may not work as expected when the signal is not properly passed to fetch, when the abort happens after the response headers are received but before the body is fully read, or when the underlying connection does not support abort. This causes requests to hang indefinitely, consuming memory and file descriptors even after the application has given up waiting.
Symptoms
Request hangs despite timeout:
```javascript const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000);
try { const response = await fetch('https://slow-api.example.com/data', { signal: controller.signal, }); clearTimeout(timeoutId); const data = await response.json(); } catch (err) { if (err.name === 'AbortError') { console.log('Request timed out'); } } // Request still hangs in the background even after AbortError! ```
Or using AbortSignal.timeout():
try {
const response = await fetch('https://slow-api.example.com/data', {
signal: AbortSignal.timeout(5000),
});
} catch (err) {
// Throws after 5 seconds
console.log(err.name); // "TimeoutError"
}
// But the underlying TCP connection may not be closedCommon Causes
- Signal not passed to fetch:
AbortControllercreated butsignalnot included in fetch options - Abort after headers received: Request aborted after response headers but body still downloading
- Response body not consumed: Aborted request leaves the body stream open
- Signal already aborted: Reusing an aborted signal for a new request
- Timeout too short: DNS resolution or TCP handshake takes longer than the timeout
- Node.js version does not support AbortSignal.timeout(): Added in Node.js 17.3
Step-by-Step Fix
Step 1: Use AbortSignal.timeout() correctly
```javascript async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) { const response = await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs), });
if (!response.ok) {
throw new Error(HTTP ${response.status}: ${response.statusText});
}
return response; }
// Usage try { const response = await fetchWithTimeout( 'https://api.example.com/data', { method: 'POST', body: JSON.stringify({ query: 'test' }) }, 10000 // 10 second timeout ); const data = await response.json(); console.log(data); } catch (err) { if (err.name === 'TimeoutError') { console.error('Request timed out after 10 seconds'); } else if (err.name === 'AbortError') { console.error('Request was aborted'); } else { console.error('Request failed:', err.message); } } ```
Step 2: Handle abort during body consumption
```javascript async function fetchWithBodyTimeout(url, timeoutMs = 10000) { const controller = new AbortController();
// Overall timeout for the entire request including body const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { const response = await fetch(url, { signal: controller.signal, });
// Read the body with the same signal const data = await response.json(); // Also respects the signal clearTimeout(timeoutId); return data; } catch (err) { clearTimeout(timeoutId);
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
throw new Error(Request to ${url} timed out after ${timeoutMs}ms);
}
throw err;
}
}
```
Step 3: Clean up aborted request resources
```javascript async function safeFetch(url, options = {}) { const { timeout = 30000, ...fetchOptions } = options; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout);
try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal, });
// Always consume or cancel the response body let data; try { data = await response.json(); } catch (bodyErr) { // If body parsing fails, consume it to free resources await response.body?.cancel(); throw bodyErr; }
clearTimeout(timeoutId); return { ok: response.ok, status: response.status, data }; } catch (err) { clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error(Fetch timeout: ${url});
}
throw err;
}
}
```
Prevention
- Always pass
signalto fetch options when using AbortController - Use
AbortSignal.timeout()for simple timeout scenarios (Node.js 17.3+) - Set timeouts that account for DNS resolution, TCP handshake, TLS negotiation, and body transfer
- Always consume or cancel response bodies to prevent resource leaks
- Check
response.bodyexists before callingcancel()on aborted requests - Add fetch timeout monitoring to track which endpoints consistently time out
- Use
undicilibrary for more granular control over request timeouts and connection pooling