Mastering the Fetch API – Modern HTTP Requests in JavaScript

The Fetch API is how JavaScript talks to servers. It replaced XMLHttpRequest a while back, and it’s built into every modern browser (and Node.js since v18). If you’ve done any frontend work in the last few years, you’ve almost certainly used it — but there’s a good chance you’re only scratching the surface. I want to walk through the patterns I end up using in real projects, not just the hello-world examples.

The Basics

A fetch request returns a Promise that resolves to a Response object:

1
2
3
4
fetch('https://api.example.com/users')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Request failed:', error));

The .json() method reads the response body and parses it as JSON. It returns a Promise, which is why there are two .then() calls. Other body-reading methods include .text(), .blob(), .formData(), and .arrayBuffer().

The async/await version is cleaner and is preferred in modern code:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function getUsers() {
try {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
const users = await response.json();
return users;
} catch (error) {
console.error('Failed to fetch users:', error);
throw error;
}
}

The critical detail that trips up beginners is that fetch() only rejects the Promise on network errors — the request could not reach the server at all. HTTP error responses like 404 or 500 do not cause a rejection. The fetch succeeds, and you get a Response object with ok: false. You must check response.ok or response.status yourself. This is by design, not a bug — the server successfully responded, just with an error status.

Sending Data

Beyond GET requests, fetch accepts a second argument: an options object. The most common options are method, headers, and body:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Jain Chen',
email: 'jain@example.com',
}),
});

if (!response.ok) {
const error = await response.json();
console.error('Server error:', error.message);
return;
}

const newUser = await response.json();
console.log('Created:', newUser);

For file uploads, use FormData instead of JSON. Do not set the Content-Type header when using FormData — the browser sets it automatically with the correct multipart boundary:

1
2
3
4
5
6
7
8
9
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
formData.append('name', 'Jain Chen');

const response = await fetch('/api/users/avatar', {
method: 'POST',
body: formData,
// Do not set Content-Type — browser handles multipart boundary
});

Aborting Requests

In single-page applications, a user might navigate away from a page before a fetch completes. Continuing to process the response wastes resources and can cause state updates on unmounted components. AbortController solves this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const controller = new AbortController();
const signal = controller.signal;

// Auto-abort after 10 seconds
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
const response = await fetch('/api/large-dataset', { signal });
const data = await response.json();
clearTimeout(timeoutId);
// Process data
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was aborted');
} else {
console.error('Request failed:', error);
}
}

The AbortError is thrown when the request is cancelled. It is important to distinguish it from actual network errors so you do not log it as a real failure.

A common pattern in React: abort a fetch when a component unmounts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
useEffect(() => {
const controller = new AbortController();

async function loadData() {
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error.message);
}
}
}

loadData();

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

The cleanup function returned from useEffect calls controller.abort(), which aborts any in-flight request when the component unmounts or the dependency changes. This prevents the classic “setState on unmounted component” warning.

Custom Request Objects

For reusable request configurations, construct a Request object:

1
2
3
4
5
6
7
const request = new Request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});

const response = await fetch(request);

Request objects shine when you want a base configuration you can tweak per-call. Instead of passing method, headers, and body to every invocation, you build a Request once and override what you need. I find this especially handy in larger codebases where you’re hitting the same API from a dozen different places.

Handling Different Response Types

Not every endpoint returns JSON. Handle each type appropriately:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// JSON (most common)
const user = await response.json();

// Plain text
const markdown = await response.text();

// Binary data (images, PDFs)
const blob = await response.blob();
const imageUrl = URL.createObjectURL(blob);
imgElement.src = imageUrl;

// Streaming large responses
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Process chunk
}

The response body is a ReadableStream under the hood. This matters when you’re dealing with large payloads — you can process chunks as they arrive instead of holding everything in memory. Server-Sent Events and streaming JSON responses are becoming more common (think ChatGPT-style output), and fetch gives you the low-level primitives to handle them natively.

Error Handling Strategy

A production-grade fetch wrapper should handle network errors, HTTP errors, and timeouts consistently:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
async function apiFetch(url, options = {}) {
const controller = new AbortController();
const timeout = options.timeout || 30000;
const timeoutId = setTimeout(() => controller.abort(), timeout);

const defaultHeaders = {
'Content-Type': 'application/json',
};

try {
const response = await fetch(url, {
...options,
headers: { ...defaultHeaders, ...options.headers },
signal: controller.signal,
});

clearTimeout(timeoutId);

if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
const error = new Error(
errorBody.message || `Request failed with status ${response.status}`
);
error.status = response.status;
error.body = errorBody;
throw error;
}

return await response.json();
} catch (error) {
clearTimeout(timeoutId);

if (error.name === 'AbortError') {
const timeoutError = new Error('Request timed out');
timeoutError.status = 408;
throw timeoutError;
}

throw error;
}
}

I’ve been using some version of this wrapper in every project for years. It handles timeouts, gives you consistent error objects, and parses JSON automatically. You can layer on retries, deduplication, or caching later — but the real win is that every component and every page in your app talks to the backend the same way. One wrapper, one mental model for errors, one place to tweak when requirements change.

Common Patterns

For parallel independent requests, use Promise.all:

1
2
3
4
5
const [users, posts, comments] = await Promise.all([
apiFetch('/api/users'),
apiFetch('/api/posts'),
apiFetch('/api/comments'),
]);

For dependent requests where one call depends on the result of another, do not use Promise.all. Chain them sequentially:

1
2
const user = await apiFetch(`/api/users/${userId}`);
const posts = await apiFetch(`/api/users/${user.id}/posts`);

For uploading with progress tracking, fetch does not directly support upload progress events. Use XMLHttpRequest for that specific use case, or use the fetch ReadableStream for download progress tracking.

Fetch is one of those APIs where the basics take ten minutes to learn, but the edge cases — timeouts, cancellation, streaming — are where you earn your salary. Pair it with async/await, always use AbortController, and wrap it once. You’ll save yourself a lot of late-night debugging.


Mastering the Fetch API – Modern HTTP Requests in JavaScript
https://toongs.org/blog/2026/06/16/19-javascript-fetch-api/
Author
Jain Chen
Posted on
June 16, 2026
Licensed under