JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
fetch and JSON for talking to servers
Send and receive JSON with fetch: await the Response, check its status, decode the body once, and POST objects as JSON.stringify with the right header.
What you will learn
- Await twice: once for the Response head, once for response.json() to parse the body
- Check response.ok or response.status yourself; 404 and 500 do not reject the promise
- Send data with method, a Content-Type: application/json header, and JSON.stringify(obj)
- A response body can be read only once, so keep the parsed value in a variable
Understanding fetch and JSON for talking to servers
A fetch call has two arrival times, and the API is shaped around that. fetch(url) returns a promise that settles as soon as the status line and headers come back, while the body bytes may still be travelling; that is why the value you get is a Response object with .status, .ok, and .headers rather than your data. Getting the data is a separate asynchronous step: response.json() reads the body to the end and parses it, which is why almost every request you write contains two awaits.
The second step exists because HTTP moves text, not JavaScript values. On the way out you must serialise with JSON.stringify, and on the way in response.json() is really "read the body as text, then JSON.parse it". Nothing converts automatically: the Content-Type header is a label for the other side, not an instruction to the browser. The round trip also flattens anything JSON cannot express, so a Date arrives as a string, undefined and functions disappear from objects, and a Map arrives as {}.
fetch rejects only when the HTTP exchange itself fails: DNS lookup failure, dropped connection, a CORS block, or an abort. A complete reply of 404 or 500 is a successful exchange as far as fetch is concerned, so the status code is data you must inspect, and response.ok is just shorthand for a status in the 200-299 range. This matters practically, because error responses are often HTML rather than JSON, and calling .json() on them turns a clear "HTTP 500" into a puzzling SyntaxError about an unexpected token.
// Stand-in for the network so the output is identical every run.
// Point fetch at a real URL and nothing below has to change.
globalThis.fetch = async () =>
new Response(JSON.stringify({ id: 7, name: 'Ada', tags: ['math', 'engines'] }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
async function loadUser(url) {
const response = await fetch(url); // settles when the headers arrive
console.log('ok:', response.ok, 'status:', response.status);
if (!response.ok) throw new Error('HTTP ' + response.status);
const user = await response.json(); // only now is the body read and parsed
console.log(typeof user, user.name, user.tags.length);
return user;
}
loadUser('/api/users/7').then((u) => console.log('done:', u.id));
console.log('this line runs before the response arrives');fetch resolves with a Response describing the transfer, and the body is a separate one-time read you must decode and status-check yourself.
Worked examples
Sending JSON with POST
Shows the three things a write request needs: a method, a Content-Type header, and a stringified body.
globalThis.fetch = async (url, options) => {
const received = JSON.parse(options.body); // what the server would do
return new Response(JSON.stringify({ created: true, echo: received }), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
};
(async () => {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Buy milk', done: false })
});
console.log('status:', res.status);
const data = await res.json();
console.log(data.created, data.echo.title, data.echo.done);
})();Example explained
Line 1body must be a string, so JSON.stringify produces the exact text that travels on the wire.
Line 2The Content-Type header tells the receiver to parse that text as JSON; without it many servers ignore the body entirely.
Line 3res.status is 201 and is readable immediately, before any body parsing happens.
Line 4data.echo is a brand new object built by JSON.parse, not a reference to the object that was sent.
A 404 is not a rejection
Demonstrates that an error status resolves normally, so try/catch alone never notices it.
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: 'no such user' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
(async () => {
try {
const res = await fetch('/api/users/999');
console.log('await finished without throwing');
console.log('ok:', res.ok, 'status:', res.status);
const body = await res.json();
console.log('server said:', body.error);
} catch (err) {
console.log('caught:', err.message);
}
})();Example explained
Line 1The catch block never runs: the request reached the server and got an answer, which counts as success.
Line 2res.ok is false because 404 falls outside 200-299, so this flag is the check you must write by hand.
Line 3The error body is still JSON, so res.json() works and gives you the server's explanation instead of a generic failure.
The body is a one-shot read
Shows that a Response body is a stream, so a second .json() call fails rather than replaying the data.
globalThis.fetch = async () =>
new Response('{"count":41}', { headers: { 'Content-Type': 'application/json' } });
(async () => {
const res = await fetch('/api/count');
const data = await res.json();
console.log(data.count + 1, 'bodyUsed:', res.bodyUsed);
try {
await res.json();
} catch (err) {
console.log('second read threw:', err.name);
}
})();Example explained
Line 1The first res.json() drains the body stream, and res.bodyUsed flips to true to record that.
Line 2The second call throws a TypeError before parsing starts, because there is nothing left to read.
Line 3Storing the result in data is the fix; a plain object can be read as many times as you like.
Line 4If two independent readers genuinely need the body, call res.clone() before the first read.
Important notes
await only works inside an async function or at the top level of a module; in a classic script tag, wrap the calls in (async () => { ... })().
A cross-origin request needs the server to send CORS headers; without them the browser rejects the fetch with a bare TypeError that carries no status, so you cannot tell a blocked read from a real 403.
Common mistakes
Logging the Response and expecting the data: you see status and headers but no fields, because the body has not been read yet.
Forgetting await in front of response.json(): the variable holds a pending Promise, so every property reads as undefined.
Passing an object directly as body: it is coerced to the string '[object Object]' and the server answers 400 for a request that looks correct in your code.
Try it yourself
Change, predict, then run
In a page's console, write an async function that fetches https://api.github.com/repos/nodejs/node, throws an Error containing response.status when response.ok is false, and otherwise logs full_name and open_issues from the parsed body. Call it a second time with a misspelled repository name so the non-ok path actually runs.
Open the JavaScript workspaceCheck your understanding
A route returns HTTP 500 with an HTML error page. Inside an async function you run: const res = await fetch('/api/user'); const data = await res.json(); What happens?
- The first await throws, because fetch rejects on any 4xx or 5xx status
- The first await succeeds and the second throws a SyntaxError while parsing HTML as JSON
- Both awaits succeed and data is null, since the body was not valid JSON
- The first await succeeds and res.json() resolves to the HTML as a string
Show answer
The exchange completed, so fetch resolves and res.status is simply 500; the failure happens in the second step, where the body starts with '<' and JSON.parse gives up. Option 0 is the tempting one, and believing it makes you hunt for the bug in the request instead of adding a response.ok check. Note also that .json() never hands back raw text; that is what .text() is for.