Introduction

The native fetch API in Node.js (since v18) does not have a built-in timeout option like axios or node-fetch. Without a timeout, requests to slow or unresponsive servers hang indefinitely, consuming resources and blocking application flow. The correct way to add a timeout is using AbortController, but the pattern is verbose and error-prone if not implemented correctly.

Symptoms

  • fetch() call hangs indefinitely on slow server
  • No error thrown - request just never resolves
  • Application becomes unresponsive waiting for external API
  • Event loop blocked by pending fetch requests
  • Connection pool exhausted by hanging requests
javascript
// No timeout - hangs forever if server does not respond
const response = await fetch('https://slow-api.example.com/data');
// If slow-api.example.com takes 5 minutes, this waits 5 minutes
// No error, no timeout, just indefinite wait

Common Causes

  • Native fetch API does not accept timeout option
  • No AbortController configured for the request
  • External API degradation causing slow responses
  • Network issues between server and external API
  • DNS resolution delays

Step-by-Step Fix

  1. 1.Add timeout with AbortController:
  2. 2.```javascript
  3. 3.// Create timeout wrapper function
  4. 4.async function fetchWithTimeout(url, options = {}) {
  5. 5.const { timeout = 10000, ...fetchOptions } = options;

const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout);

try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal, }); return response; } finally { clearTimeout(id); } }

// Usage const response = await fetchWithTimeout('https://api.example.com/data', { timeout: 5000, // 5 second timeout method: 'POST', body: JSON.stringify({ query: 'test' }), }); ```

  1. 1.Handle timeout errors gracefully:
  2. 2.```javascript
  3. 3.async function fetchWithRetry(url, options = {}) {
  4. 4.const { timeout = 10000, retries = 3, ...fetchOptions } = options;

for (let attempt = 1; attempt <= retries; attempt++) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout);

try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal, });

if (!response.ok) { throw new Error(HTTP ${response.status}: ${response.statusText}); }

return await response.json(); } catch (error) { if (error.name === 'AbortError') { console.log(Attempt ${attempt}: Request timed out after ${timeout}ms); } else { console.log(Attempt ${attempt}: ${error.message}); }

if (attempt === retries) { throw error; // All retries exhausted }

// Exponential backoff await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000) ); } finally { clearTimeout(id); } } } ```

  1. 1.Set timeout per-request type:
  2. 2.```javascript
  3. 3.const TIMEOUTS = {
  4. 4.fast: 3000, // Health checks, simple queries
  5. 5.normal: 10000, // Standard API calls
  6. 6.slow: 30000, // Bulk operations, reports
  7. 7.};

async function apiCall(endpoint, type = 'normal') { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), TIMEOUTS[type]);

try { const response = await fetch(https://api.example.com${endpoint}, { signal: controller.signal, }); return response.json(); } finally { clearTimeout(id); } } ```

  1. 1.Use TimeoutSignal utility (Node.js 18.3+):
  2. 2.```javascript
  3. 3.// Node.js 18.3+ has AbortSignal.timeout()
  4. 4.const response = await fetch('https://api.example.com/data', {
  5. 5.signal: AbortSignal.timeout(5000), // 5 second timeout
  6. 6.});

// Cleaner than AbortController + setTimeout // Throws DOMException with name 'TimeoutError' on timeout ```

  1. 1.Add timeout to Express proxy middleware:
  2. 2.```javascript
  3. 3.const express = require('express');
  4. 4.const app = express();

app.get('/proxy/*', async (req, res) => { const targetUrl = req.params[0]; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000);

try { const response = await fetch(targetUrl, { signal: controller.signal, headers: req.headers, });

const data = await response.json(); res.json(data); } catch (error) { if (error.name === 'AbortError') { res.status(504).json({ error: 'Gateway timeout' }); } else { res.status(502).json({ error: 'Bad gateway' }); } } finally { clearTimeout(timeout); } }); ```

Prevention

  • Always wrap fetch calls with timeout logic
  • Use AbortSignal.timeout() for Node.js 18.3+
  • Set different timeouts for different types of requests
  • Monitor request latency in production dashboards
  • Add circuit breakers for external API dependencies
  • Use fetch with keepalive: true for fire-and-forget requests
  • Consider using undici for more control over connection pooling and timeouts