Working with JSON
Parsing and sending JSON with fetch, handling errors correctly, and a real-world example.
Parsing JSON responses
Most APIs respond with a JSON body. fetch() doesn't parse it for you automatically — you call .json() on the Response object, which itself returns a promise (it has to read and decode the response stream first):
const response = await fetch('/api/products/1');
const product = await response.json();
console.log(product.name, product.price);
If the body isn't valid JSON, .json() rejects — always wrap it in a try/catch (covered below), rather than assuming every response will parse cleanly.
Sending JSON request bodies
Sending JSON is the reverse operation: convert a JavaScript object into a JSON string with JSON.stringify(), and tell the server what format it's receiving with a Content-Type header:
async function createProduct(product) {
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
return response.json();
}
const created = await createProduct({ name: 'Desk Lamp', price: 19.99 });
response.ok — fetch does not reject on HTTP errors
This is the single most common source of bugs in fetch() code: fetch() only rejects on a network-level failure (DNS failure, no connectivity, CORS block). A 404 Not Found or a 500 Internal Server Error is still a successful HTTP exchange as far as fetch() is concerned — the promise resolves normally, just with an error status code.
const response = await fetch('/api/products/999999'); // doesn't exist
console.log(response.ok); // false
console.log(response.status); // 404
// response.json() will likely still "succeed" and parse an error body,
// but the request itself did not throw
Always check response.ok (true for status codes 200–299) or response.status explicitly, and throw your own error if the request wasn't actually successful:
async function fetchProduct(id) {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
try/catch error handling with fetch
Combining try/catch with a manual response.ok check covers both failure modes: network-level failures (which reject the fetch() promise itself) and application-level failures (a valid HTTP response with an error status code):
async function fetchProduct(id) {
try {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
// Server responded, but with an error status (404, 500, etc.)
const errorBody = await response.json().catch(() => null);
throw new Error(errorBody?.message ?? `HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
// Either a network failure (fetch() itself rejected)
// or the error we threw above for a bad status code
console.error('Could not load product:', error.message);
throw error; // re-throw if the caller needs to react too
}
}
A complete real-world example: fetching and rendering a user list
Putting it all together — fetching a list of users from an API, handling loading and error states, and rendering the result into the DOM:
<ul id="user-list"></ul>
<p id="status"></p>
async function loadUsers() {
const list = document.getElementById('user-list');
const status = document.getElementById('status');
status.textContent = 'Loading users...';
list.innerHTML = '';
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const users = await response.json();
if (users.length === 0) {
status.textContent = 'No users found.';
return;
}
status.textContent = '';
for (const user of users) {
const item = document.createElement('li');
item.textContent = `${user.name} (${user.email})`;
list.appendChild(item);
}
} catch (error) {
status.textContent = `Failed to load users: ${error.message}`;
}
}
loadUsers();
This example covers every step you'll repeat in almost every real AJAX call: show a loading state, make the request, check for HTTP-level success, parse the JSON, handle the empty case, render the result, and catch anything that goes wrong along the way — all without a single full page reload.
Common mistakes
- Assuming a resolved
fetch()promise means success — it only means the network request completed; you still must checkresponse.okorresponse.status. - Calling
.json()on an error response and assuming it will always contain amessagefield — defensively fall back to a generic message if the shape isn't guaranteed. - Not showing any loading or error state in the UI, leaving the user staring at a blank screen with no feedback while a request is in flight or after it fails.
Interview questions
Q: Does fetch() throw an error for a 404 or 500 response?
No. fetch() only rejects its promise for network-level failures (the request never reached a server, or was blocked by something like CORS). An HTTP response with a 4xx or 5xx status code is still a "successful" fetch as far as the promise is concerned — you must check response.ok or response.status yourself and throw your own error if needed.
Q: How would you handle both network errors and HTTP error status codes with the same fetch call?
Wrap the call in try/catch to catch network-level rejections, and inside the try block, check response.ok immediately after awaiting the fetch — if it's false, throw a new Error with the status code or a message parsed from the error body. That thrown error then flows into the same catch block as a genuine network failure, so calling code only needs to handle one error path.