JavaScript Intermediate #6 fetch API and Error Handling
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 #
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.
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.
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.
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.
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:
method— default ‘GET’; POST/PUT/PATCH/DELETE etc.headers— declare body format withContent-Typebody— the body. Objects must be stringified withJSON.stringify
Sending FormData #
For multipart bodies like file uploads:
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 #
const token = localStorage.getItem('token');
const res = await fetch('/api/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});To send cookies, the credentials option:
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.
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.
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.
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.
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.
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.
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.
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:
| Option | Pros | Cons |
|---|---|---|
Built-in fetch | zero deps, standard | 4xx check yourself, boilerplate |
| ky | small and modern, fetch-based | adds a dependency |
| axios | rich features, longstanding standard | heavy, lots of custom behavior |
| TanStack Query | caching/sync included | learning 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:
fetchreturns a Promise; the response is aResponseobject- Read the body with
.json(),.text(),.blob() - 4xx/5xx don’t throw — check
res.okyourself - 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.