JavaScript Intermediate #6 fetch API and Error Handling

6 min read

After #5 Optional Chaining and Nullish Coalescing, this post is about JavaScript’s tool for talking to the outside world — the standard fetch API that works in both browsers and Node.

fetch basics #

simplest fetch
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);

Done in three lines. fetch returns a Promise — await it (the pattern from #2 Async Intro).

The result of fetch is a Response object. The method to read the body depends on how you want to interpret it.

response interpretation methods
await response.json();        // JSON parse
await response.text();        // text as-is
await response.blob();        // binary (image/file etc.)
await response.arrayBuffer(); // ArrayBuffer (low-level)
await response.formData();    // FormData

Pitfall 1 — 4xx/5xx don’t trigger catch #

JavaScript’s fetch doesn’t reject for HTTP 4xx/5xx. It rejects only when the network itself fails.

this doesn't throw
try {
  const res = await fetch('/api/not-found');
  // even with status 404, we get here
  console.log(res.status);   // 404
} catch (err) {
  // not entered unless network failed
}

This behavior differs from other languages/libraries (axios, etc.) and often catches people off guard. Check the status yourself.

recommended pattern — check status
const res = await fetch('/api/users');
if (!res.ok) {
  throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();

res.ok is true if the status is 2xx. Skipping this check leads to JSON-parsing a 4xx response and producing another error.

Method and body — POST/PUT/DELETE #

Default is GET. For others, pass an options object as the second argument.

POST request
const res = await fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ name: 'Curtis', age: 30 }),
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created = await res.json();

Three keys:

  1. method — default ‘GET’; POST/PUT/PATCH/DELETE etc.
  2. headers — declare body format with Content-Type
  3. body — the body. Objects must be stringified with JSON.stringify

Sending FormData #

For multipart bodies like file uploads:

FormData
const formData = new FormData();
formData.append('name', 'Curtis');
formData.append('avatar', fileInput.files[0]);

const res = await fetch('/api/upload', {
  method: 'POST',
  body: formData,
  // intentionally no Content-Type — the browser sets it
});

Don’t set Content-Type for FormData. The browser builds the correct header including the boundary automatically.

Headers and authentication #

auth token
const token = localStorage.getItem('token');

const res = await fetch('/api/me', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
});

To send cookies, the credentials option:

include cookies
const res = await fetch('/api/me', {
  credentials: 'include',  // send cookies even cross-origin
});

Default is 'same-origin' (cookies only on the same domain). When external APIs use cookie auth, you’ll need 'include'.

Query parameters — URLSearchParams #

Building query strings by hand is risky. URLSearchParams handles encoding for you.

safe query
const params = new URLSearchParams({
  q: 'search term with spaces',
  page: '1',
  sort: 'date',
});

const res = await fetch(`/api/posts?${params}`);
// /api/posts?q=search+term+with+spaces&page=1&sort=date

+, &, non-ASCII, spaces — all auto URL-encoded.

AbortController — cancel a request #

When you want to cancel a long-running request mid-flight.

AbortController
const controller = new AbortController();

setTimeout(() => controller.abort(), 5000);   // cancel after 5s

try {
  const res = await fetch('/api/slow', {
    signal: controller.signal,
  });
  console.log(await res.json());
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('canceled');
  } else {
    throw err;
  }
}

This is the modern answer to the timeout briefly mentioned in #2 Async Intro. Cleaner than Promise.race + a timer.

AbortSignal.timeout() — ES2022 #

A common pattern, so a short form was added.

timeout in one line
const res = await fetch('/api/slow', {
  signal: AbortSignal.timeout(5000),
});

Auto-aborts when 5 seconds pass. No need to build an AbortController yourself.

Concurrent request cancellation — component unmount pattern #

When a React component unmounts but a fetch in progress arrives later, calling setState on the gone component triggers a warning. AbortController is the standard answer.

in a component
function UserPage({ id }) {
  const [user, setUser] = useState(null);

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

    fetch(`/api/users/${id}`, { signal: controller.signal })
      .then((r) => r.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort();   // cleanup
  }, [id]);

  // ...
}

return () => controller.abort() is the key. On unmount or dependency change, the in-flight fetch is canceled.

Streaming response — for await #

When you want to receive a large response in chunks.

reading a stream
const res = await fetch('/api/large');

for await (const chunk of res.body) {
  console.log(chunk);   // Uint8Array piece
}

res.body is a ReadableStream. With for await (#3 Iterators and Generators), receive chunks in order. Comes up with AI streaming responses, big files, server-sent events, and similar.

fetch in Node #

Old Node didn’t have fetch — you had to install node-fetch. Since Node 18, fetch is built-in. Use it directly without installing anything.

same in Node
const res = await fetch('https://api.example.com/users');
const users = await res.json();

Scripts, CLI tools, server-side — same API to communicate.

Logging and retries — small wrapper #

When the same patterns (status check, error logging) repeat in many places, a wrapper function is cleaner.

simple wrapper
async function api(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${url}`);
  }

  return res.json();
}

// usage
const users = await api('/api/users');
const created = await api('/api/users', {
  method: 'POST',
  body: JSON.stringify({ name: 'Curtis' }),
});

Build on this — manage retries, timeouts, auth tokens in one place. In a big app, similar wrappers tend to grow into the team’s own shape.

Library or just fetch? #

There are libraries on top of fetch (axios, ky, ofetch). Each has tradeoffs:

OptionProsCons
Built-in fetchzero deps, standard4xx check yourself, boilerplate
kysmall and modern, fetch-basedadds a dependency
axiosrich features, longstanding standardheavy, lots of custom behavior
TanStack Querycaching/sync includedlearning cost

For small projects, libraries, learning code — fetch alone suffices. For larger apps the flow tends to add a wrapper or a data-fetching library (TanStack Query).

Wrap-up #

What we covered:

  • fetch returns a Promise; the response is a Response object
  • Read the body with .json(), .text(), .blob()
  • 4xx/5xx don’t throw — check res.ok yourself
  • POST takes method/headers/body options + JSON.stringify
  • FormData sets Content-Type automatically
  • Safe query encoding with URLSearchParams
  • Cancel with AbortController / AbortSignal.timeout()
  • Stream with for await (chunk of res.body)
  • Built-in fetch in Node 18+
  • Small wrapper / library like TanStack Query in larger apps

In the next post (#7 Working with JSON and Serialization) we cover what comes up often when exchanging data via JSON — parse/stringify options, Date/special-value handling, and pitfalls of the deep-copy trick.

X