Browse by section

Web Design 日本語

Practical fetch(): Timeouts, Aborts and Streaming

The two genuinely useful additions around fetch() in recent years are AbortSignal.timeout() (a one-line timeout) and AbortSignal.any() (combining several abort conditions). Both reached Baseline in 2024.

One correction first. fetch() still does not reject on 404 or 500. You will find articles claiming an update made HTTP errors throw automatically; no such change happened. Checking response.ok remains mandatory.

With that established, this article covers aborting, timeouts, streaming, and parallel fetching in code you can use directly.

Sponsored

When does fetch() actually reject?

Only when the network transaction itself fails. Once the server responds at all, the promise fulfils—even with a 500.

Situation Promise result
200 OK Fulfilled (response.ok === true)
404 / 500 Fulfilled (response.ok === false)
Network down, DNS failure Rejected (TypeError)
Blocked by CORS Rejected (TypeError)
Aborted via abort() Rejected (AbortError)
Timed out Rejected (TimeoutError)

So the minimum usable helper looks like this:

async function getJson(url, options = {}) {
  const res = await fetch(url, options);

  if (!res.ok) {
    // without this, a 404 flows straight through
    throw new Error(`HTTP ${res.status} ${res.statusText}`);
  }

  return res.json();
}

Including res.status in the error makes triage from logs much faster later.

Timeouts in one line with AbortSignal.timeout()

fetch() has no default timeout. A request that never gets a response simply stays outstanding. This used to require hand-rolling AbortController plus setTimeout; now it is one line.

// abort automatically after 5 seconds
const res = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000),
});

A timeout throws TimeoutError, which is distinguishable from a user-initiated AbortError, so you can message them differently.

try {
  const res = await fetch('/api/data', { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  render(data);
} catch (err) {
  if (err.name === 'TimeoutError') {
    showMessage('No response. Please check your connection.');
  } else if (err.name === 'AbortError') {
    // user cancelled — show nothing
  } else {
    showMessage('Failed to load.');
    console.error(err);
  }
}

For comparison, the old hand-rolled version—where forgetting clearTimeout leaves a timer running:

// no longer needed
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

try {
  const res = await fetch('/api/data', { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timer); // easy to forget
}

Sponsored

Combining user cancellation and a timeout

In a search-as-you-type field you need both: cancel the previous request and time out. AbortSignal.any() merges them into one signal.

let currentController = null;

async function search(keyword) {
  // discard the previous request
  currentController?.abort();
  currentController = new AbortController();

  // stops on user cancellation OR after 8 seconds
  const signal = AbortSignal.any([
    currentController.signal,
    AbortSignal.timeout(8000),
  ]);

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    if (err.name === 'AbortError') return null; // simply superseded
    throw err;
  }
}

AbortSignal.any() requires Safari 17.4 or later. Think of it as Promise.race() for signals: if any one aborts, the whole thing aborts.

Streaming: process while receiving

response.body is a ReadableStream, so you can start processing before everything has arrived. This is essential for rendering generative AI responses.

async function streamText(url, onChunk) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  if (!res.body) throw new Error('Streaming not supported');

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      onChunk(value);
    }
  } finally {
    reader.releaseLock();
  }
}

await streamText('/api/stream', (text) => {
  document.getElementById('output').append(text);
});

The key point is piping through TextDecoderStream. Decoding raw bytes yourself with TextDecoder corrupts multi-byte characters that straddle a chunk boundary; TextDecoderStream handles the boundaries correctly.

For download progress, compare received bytes against Content-Length:

const res = await fetch(url);
const total = Number(res.headers.get('Content-Length')) || 0;
const reader = res.body.getReader();

let received = 0;
const chunks = [];

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  chunks.push(value);
  received += value.length;

  if (total) {
    updateProgress(received / total); // 0 to 1
  }
}

const blob = new Blob(chunks);

Content-Length may be absent with compressed or chunked transfer encoding. Without the if (total) guard, your progress bar shows Infinity or NaN.

Sponsored

Fetching in parallel: all versus allSettled

When calling several APIs at once, the choice depends on whether one failure should fail everything.

// 1. useless without all of them → Promise.all
const [user, posts] = await Promise.all([
  getJson('/api/user'),
  getJson('/api/posts'),
]);

// 2. render what you can → Promise.allSettled
const results = await Promise.allSettled([
  getJson('/api/user'),
  getJson('/api/recommend'), // non-critical
  getJson('/api/notice'),
]);

for (const r of results) {
  if (r.status === 'fulfilled') {
    render(r.value);
  } else {
    console.warn('Failed:', r.reason);
  }
}

When any single success is enough, use Promise.any()—covered separately in how to use Promise.any().

Options worth knowing

Option Purpose
keepalive: true Complete the request even as the page unloads (analytics; 64KB limit)
cache: 'no-store' Bypass the cache entirely
credentials: 'include' Send cookies cross-origin
priority: 'low' Lower the fetch priority (Chromium)
redirect: 'manual' Do not follow redirects automatically

Logging on page exit used to mean navigator.sendBeacon(), but keepalive lets you set your own headers and method.

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;

  fetch('/api/log', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ event: 'leave' }),
    keepalive: true,
  });
});

Do not detect departure with unload or beforeunload—they frequently do not fire on mobile. Use visibilitychange becoming hidden.

Summary

  • fetch() does not reject on 404 or 500. response.ok is still required
  • Timeouts: AbortSignal.timeout(ms), one line. The error name is TimeoutError
  • Combine cancellation and timeout with AbortSignal.any([...]) (Safari 17.4+)
  • Stream with pipeThrough(new TextDecoderStream()) to avoid mojibake at chunk boundaries
  • Content-Length is not always present—always check before computing progress
  • Promise.all when you need everything, Promise.allSettled when partial results are fine
  • Exit logging: keepalive: true plus visibilitychange

AbortController itself has existed since 2017; what changed is that AbortSignal.timeout() and any() finally removed the boilerplate. For event handling patterns in modern JavaScript, see working with classes and events.