ESC

Type to search the knowledge base.

Fetch API Fundamentals

How fetch really works — Response.ok, one-shot bodies, JSON errors, AbortController, credentials, and production footguns.

beginner5 min read
  • javascript
  • fetch
  • http
  • async
  • abortcontroller

fetch is the browser’s promise-based HTTP primitive. It replaced most XMLHttpRequest usage for new code, but it is not a high-level client: it does not throw on HTTP 404/500 by default, does not retry, and does not parse JSON until you ask.

If you only remember fetch(url).then(r => r.json()), you will ship silent failures and un-abortable spinners. Production-ready fetch is a short checklist, not a one-liner.

Promises and microtasks underneath: Promises, Event Loop.

The problem fetch solves

You need to load data or send mutations without full page navigation:

const res = await fetch('/api/profile');
const data = await res.json();

That happy path hides three sharp edges:

  1. Network failure vs HTTP error status
  2. Body consumption rules
  3. Cancellation when the user navigates away

Model: Request in, Response promise out

const response = await fetch(input, init);
// response is a Response — headers/status available
// body is a ReadableStream — read once via .json() / .text() / .blob() / …
Piece Role
input URL string or Request object
init method, headers, body, credentials, signal, cache, mode, …
Returned promise Rejects on network failure / abort; fulfills on HTTP responses including 4xx/5xx
response.ok status in 200–299
Body methods Async readers; each body is one-shot
const res = await fetch('/api/items');
if (!res.ok) {
  throw new Error(`HTTP ${res.status}`);
}
const items = await res.json();

Never skip the ok check unless you intentionally handle non-2xx as data.

HTTP errors do not reject

// 404 still resolves the fetch promise
const res = await fetch('/api/missing');
console.log(res.status); // 404
console.log(res.ok); // false
// await res.json() might still parse an error payload

Rejection cases include DNS failure, offline, CORS hard failures, and abort. Treat status handling as your job.

Reading the body (once)

const res = await fetch('/api/data');
const text = await res.text();
// await res.json(); // throws — body already used

If you need two forms, clone first:

const res = await fetch('/api/data');
const clone = res.clone();
const asText = await res.text();
const asJson = await clone.json(); // only works if text was valid JSON

Or branch on Content-Type before choosing a reader.

JSON POST with correct headers

async function createItem(payload) {
  const res = await fetch('/api/items', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify(payload),
  });

  if (!res.ok) {
    const errBody = await res.text();
    throw new Error(`Create failed: ${res.status} ${errBody}`);
  }

  return res.json();
}

Forgetting Content-Type on JSON bodies is a classic “server got empty fields” bug. FormData bodies should not set Content-Type manually — the browser sets the multipart boundary.

const form = new FormData();
form.append('file', fileInput.files[0]);
await fetch('/api/upload', { method: 'POST', body: form });

AbortController — cancel in-flight work

const controller = new AbortController();

const promise = fetch('/api/slow', { signal: controller.signal })
  .then((res) => {
    if (!res.ok) throw new Error(String(res.status));
    return res.json();
  });

// user navigates away or types a new query
controller.abort();

try {
  await promise;
} catch (err) {
  if (err.name === 'AbortError') {
    // expected — not a product error toast
    return;
  }
  throw err;
}

React effect pattern:

useEffect(() => {
  const controller = new AbortController();

  (async () => {
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: controller.signal,
      });
      if (!res.ok) throw new Error(String(res.status));
      const data = await res.json();
      setResults(data);
    } catch (err) {
      if (err.name === 'AbortError') return;
      setError(err);
    }
  })();

  return () => controller.abort();
}, [query]);

Pair with useEffect Fundamentals. Related: AbortController.

Credentials and cookies

Default credentials is "same-origin" in modern browsers for fetch (verify your target matrix). Cross-origin cookie auth needs:

await fetch('https://api.example.com/me', {
  credentials: 'include',
});

Server must allow the origin with explicit CORS (Access-Control-Allow-Credentials: true and a concrete Allow-Origin, not *). Misconfigured CORS shows up as a network failure in the console, not a JSON body.

CORS in one breath

Browsers enforce same-origin policy on frontends. Cross-origin fetch requires the server to opt in via CORS headers. Preflight (OPTIONS) happens for “non-simple” requests (custom headers, JSON content-type, etc.). You cannot fix CORS from client JS alone without a proxy.

Timeouts

fetch has no built-in timeout. Compose abort + timer:

function fetchWithTimeout(url, { timeoutMs = 8000, ...init } = {}) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeoutMs);

  return fetch(url, { ...init, signal: controller.signal }).finally(() => {
    clearTimeout(id);
  });
}

If the caller also passes a signal, use AbortSignal.any([signal, controller.signal]) where supported, or wire abort listeners to merge signals.

Streaming and large responses

const res = await fetch('/api/large');
const reader = res.body.getReader();
// read chunks — useful for progress UIs

Most app code stops at .json(). Know streams exist for downloads and progressive parsing.

Parallel requests

const [user, posts] = await Promise.all([
  fetch('/api/user').then((r) => {
    if (!r.ok) throw new Error('user');
    return r.json();
  }),
  fetch('/api/posts').then((r) => {
    if (!r.ok) throw new Error('posts');
    return r.json();
  }),
]);

Promise.all fails fast; Promise.allSettled when partial UI is acceptable. See promise combinators on this site when you extend this pattern.

Footguns

  1. Assuming 404 rejects — check res.ok / status.
  2. Double-reading the body after logging res.text() in a debug path.
  3. No abort on unmount — state updates and wasted bandwidth.
  4. JSON.stringify without Content-Type.
  5. Catching AbortError as a red toast.
  6. Caching GET mutations — use proper methods; understand cache option.
  7. Leaky error messages — surfacing raw server HTML error pages to users.
  8. Mixing baseURL mental models — relative URLs resolve against the page URL, not your API host, unless you build absolute URLs.

Interview angle

Prompt: “How do you handle errors with fetch?”

Strong answer: “fetch only rejects on network failure or abort. HTTP error statuses still fulfill; I check response.ok or status ranges, then parse the body if useful. I use AbortController for cancellation and treat AbortError as non-fatal. Bodies are one-shot streams.”

Follow-ups: CORS, credentials, difference from Axios interceptors, how async/await sugar relates to .then chains.

Further reading

Related guides