⌨️ Web development

Fetch and async/await in JavaScript: errors, timeouts, retries

Using fetch with async/await properly: check response.ok, handle network errors and timeouts with AbortController, run requests in parallel, retry.

The fetch API is the standard way to make HTTP requests from the browser, and it is also available in modern versions of Node.js. With async and await, asynchronous code reads almost like sequential code. That very simplicity, however, hides a few traps: errors that are not caught, requests that hang forever, responses read twice. Let us look at how to write robust calls.

The basic form

async function loadArticles() {
  const response = await fetch('/api/articles');
  const data = await response.json();
  return data;
}

It works, as long as everything goes well. But this code already has an important flaw.

fetch does not fail on HTTP errors

This is the most common surprise: the promise returned by fetch is rejected only for network errors (server unreachable, connection dropped, request blocked by CORS). A 404 or 500 response is, as far as fetch is concerned, a successful response: the promise resolves normally.

That is why you must always check response.ok, which is true for codes from 200 to 299:

async function loadArticles() {
  const response = await fetch('/api/articles');
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  return response.json();
}

Without this check, an HTML error page would end up in response.json(), which would throw an unhelpful syntax error.

A more useful custom error

When the API returns structured errors, it is worth keeping them:

class HttpError extends Error {
  constructor(response, body) {
    super(`HTTP ${response.status}`);
    this.status = response.status;
    this.body = body;
  }
}

async function requestJson(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: { 'Accept': 'application/json', ...options.headers }
  });
  const type = response.headers.get('content-type') || '';
  const body = type.includes('application/json')
    ? await response.json()
    : await response.text();
  if (!response.ok) throw new HttpError(response, body);
  return body;
}

We read the body only once: a response stream can be consumed only once, and calling json() and then text() on the same response throws an error. If you need to read it twice, use response.clone().

Handling errors with try/catch

With await, errors are caught with an ordinary try/catch:

try {
  const articles = await requestJson('/api/articles');
  render(articles);
} catch (error) {
  if (error instanceof HttpError && error.status === 401) {
    redirectToLogin();
  } else {
    showMessage('Unable to load articles. Please try again shortly.');
  }
}

Distinguishing the cases lets you give users useful messages: an authorisation problem is solved differently from a temporarily unavailable server.

Watch out for async functions called without await or .catch(): if they fail, the error becomes an "unhandled rejection" that can go unnoticed.

Timeouts with AbortController

fetch has no default timeout: if the server does not respond, the request can remain pending for a very long time. The solution is AbortSignal:

const response = await fetch('/api/report', {
  signal: AbortSignal.timeout(8000) // 8 seconds
});

When the time runs out the promise is rejected with an error whose name is TimeoutError. To cancel manually, for example when the user changes page or types a new search, use an AbortController:

let controller;

async function search(text) {
  controller?.abort();          // cancel the previous search
  controller = new AbortController();
  try {
    const r = await fetch(`/api/search?q=${encodeURIComponent(text)}`, {
      signal: controller.signal
    });
    return await r.json();
  } catch (e) {
    if (e.name === 'AbortError') return null; // cancelled: not an error
    throw e;
  }
}

This pattern also prevents a subtle bug: without cancellation, a slow response to an old search could arrive after the new one and overwrite its results.

Note encodeURIComponent too: values placed in a URL must always be encoded, or built with URLSearchParams.

Parallel requests

If requests are independent, do not wait for them one at a time:

// Slow: sequential
const user = await requestJson('/api/user');
const notifications = await requestJson('/api/notifications');

// Faster: parallel
const [user2, notifications2] = await Promise.all([
  requestJson('/api/user'),
  requestJson('/api/notifications')
]);

Promise.all fails as soon as one of the promises fails. If you want every result, even partial ones, use Promise.allSettled, which returns for each one its status (fulfilled or rejected) and the value or the error.

Retries with increasing delays

For temporary errors (unstable network, 503, 429) it can make sense to retry, but with judgement:

async function withRetries(fn, attempts = 3) {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (e) {
      const transient = !(e instanceof HttpError) || [429, 502, 503, 504].includes(e.status);
      if (!transient || i >= attempts - 1) throw e;
      await new Promise(r => setTimeout(r, 500 * 2 ** i)); // 0.5s, 1s, 2s…
    }
  }
}

Retry only idempotent operations, such as reads: automatically repeating a POST that creates an order can duplicate it. Never retry 4xx errors that indicate a problem with the request, because the result will not change.

In short

  • fetch rejects the promise only for network errors: always check response.ok.
  • Read the response body only once, choosing the method according to the Content-Type.
  • Use AbortSignal.timeout for timeouts and AbortController to cancel outdated requests.
  • Run independent requests in parallel with Promise.all or Promise.allSettled.
  • Retry only transient errors and idempotent operations, with increasing delays.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also