Node.js Fetch Timeout: Cancel the Whole Request

Set a Node.js fetch timeout by passing an AbortSignal to fetch and keeping it attached while you read the response body. Combine a deadline with caller cancellation using AbortSignal.any(). Check HTTP status separately, and consume or cancel every response body so an unsuccessful request does not leave connection resources waiting for collection.

A stopwatch and stop barrier interrupt data packets travelling between two servers.

How do you set a Node.js fetch timeout?

Use AbortSignal.timeout(milliseconds) for an individual request. It creates a signal that aborts after the specified delay. There is no timer handle to manage.

The APIs used below are available in Node 22 and 24. The Node global API reference records timeout support from 16.14.0 and 17.3.0, and signal composition from 18.17.0 and 20.3.0. Built-in fetch stopped being experimental in Node 21. You do not need an HTTP package for this helper.

Save this as fetch-json.mjs. It accepts a URL string or URL object and deliberately supports GET requests only:

export async function getJson(
  url,
  { timeoutMs = 3000, signal: callerSignal } = {},
) {
  const deadline = AbortSignal.timeout(timeoutMs);
  const signal = callerSignal
    ? AbortSignal.any([callerSignal, deadline])
    : deadline;

  try {
    signal.throwIfAborted();

    const response = await fetch(url, {
      signal,
      headers: { accept: 'application/json' },
    });

    if (!response.ok) {
      try {
        await response.body?.cancel();
      } catch {
        // Preserve the HTTP error unless cancellation won.
      }
      throw new Error('Upstream HTTP ' + response.status);
    }

    return await response.json();
  } catch (error) {
    // Normalise cancellation during fetch or body reading.
    signal.throwIfAborted();
    throw error;
  }
}

Three seconds is an example budget, not a recommended default for every endpoint. Choose it from the time your own route can afford to spend waiting.

The helper promises parsed JSON. A successful response without a JSON body will fail during parsing. Keep that contract explicit rather than quietly returning an empty object for a broken upstream response.

Why can a request time out after headers arrive?

Fetch resolves when the response headers arrive. Reading the body is another asynchronous operation. A server can send its headers promptly, then stall halfway through the JSON.

MDN’s fetch guide documents cancellation after fetch has resolved: the later body read can still reject. That is why the helper waits for response.json() inside the same try block.

Keep the await in return await response.json() here. Returning the promise directly would let its rejection bypass this function’s catch block, losing the cancellation normalisation.

A common hand-written timer has the opposite bug: it clears the timeout immediately after await fetch(). The body then has no application deadline. If you use an AbortController and a manual timer, clear that timer in a finally block after the body read finishes.

Headers are not completion.

Also, a timeout signal is cooperative cancellation. It does not interrupt synchronous JavaScript that is already running. A huge JSON parse or a blocked event loop needs separate attention; a three-second signal is not a CPU execution limit.

How do you honour caller cancellation too?

A helper that always supplies its own signal can accidentally discard cancellation from its caller. For example, a cancelled export may continue fetching each remaining page.

AbortSignal.any() combines those lifetimes. The first source to abort determines the combined signal’s reason. An already-aborted caller signal also aborts the combination immediately.

Add this runnable caller in example.mjs beside the helper, then run node example.mjs:

import { getJson } from './fetch-json.mjs';

const controller = new AbortController();

// Simulate a caller cancelling before work begins.
controller.abort();

try {
  await getJson('https://nodejs.org/api/documentation.json', {
    timeoutMs: 3000,
    signal: controller.signal,
  });
} catch (error) {
  if (error?.name === 'TimeoutError') {
    console.error('The upstream exceeded its time budget');
  } else if (error?.name === 'AbortError') {
    console.error('The caller cancelled the operation');
  } else {
    throw error;
  }
}

This example makes no network request because the caller has already cancelled. Remove the abort call to fetch the document.

A signal cannot be reset. Give each new operation a fresh controller. Share a caller signal across several requests only when cancelling that caller should stop all of them.

Which error should your application report?

The timeout signal uses a TimeoutError reason. Calling controller.abort() without a reason uses AbortError. A caller can supply its own reason, so the two name checks above are a convention for these examples, not an exhaustive error taxonomy.

The helper checks the combined signal in its catch block. If cancellation has happened, it throws that reason, including when the body reader returned a different cancellation error. Otherwise, it preserves the original failure. This deliberately gives cancellation priority if it coincides with another error.

An HTTP failure is separate. Fetch does not reject just because the server returns 404 or 503; response.ok is what makes our helper reject those responses.

In application logs, record the upstream service, elapsed time, HTTP status when available, and whether cancellation won. Carry the same request identifier through those records using request context with AsyncLocalStorage. Avoid logging full URLs when their query strings contain credentials or customer data.

What happens to an unread response body?

Throwing immediately after a bad status leaves its body unread. Undici’s connection-management guidance says to consume or cancel response bodies rather than rely on Node’s garbage collector. Otherwise, connection usage can grow and requests can stall.

The helper cancels unsuccessful bodies because its contract only exposes the status. If your application needs the upstream’s structured error details, read those instead, under the same deadline. Do not fetch an unlimited error page merely to print it.

There is a second resource detail: a timeout signal has no public method to cancel its timer. Completing early, or aborting the caller signal, does not cancel that timeout. For frequent operations with long deadlines, an AbortController plus a timer cleared in finally gives you explicit timer cleanup. Remove any abort listeners your own code installs when their work finishes.

Should you retry when the deadline expires?

Start without automatic retries.

For an interactive route, decide on one overall budget before adding attempts. Giving each retry another three seconds turns a short timeout into a much longer wait. Pass an overall cancellation signal through the retry loop and its backoff, then use a shorter timeout for each attempt if needed.

A cancelled caller should stop retries. A timed-out write also leaves an unanswered question: did the upstream commit it before the connection ended? Only retry that operation when its contract provides a way to avoid duplicate effects.

I would use this small GET helper for bounded JSON calls, adding endpoint-specific recovery once failures are understood. For downloads or long-lived server event streams, I would use streaming consumption and a timeout policy designed for that workload; a three-second JSON helper is the wrong fit.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch