Bodies, headers and status codes
Which headers you control, how to read the response body, and how to branch on status codes without duplicating error handling everywhere.
Request and response headers
| Header | Set it when |
|---|---|
Content-Type | you send a body: application/json, application/x-www-form-urlencoded, multipart/form-data |
Accept | you want to declare what you can parse |
Authorization | the API uses a bearer token |
If-None-Match | you send back a stored ETag and accept a 304 |
X-CSRF-Token | a same-site cookie session needs a token for writes |
const res = await fetch('/api/me', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + token,
'X-CSRF-Token': getCsrfToken()
}
});
res.headers.get('content-type'); // 'application/json; charset=utf-8'
res.headers.get('cache-control');
res.headers.get('link'); // pagination, when the API sends itSome headers can never be set from script: Host, Content-Length, Cookie and Origin belong to the browser. On the way back, only a small list of response headers is readable unless the server names the others in Access-Control-Expose-Headers.
Reading the response body
const res = await fetch('/report.csv');
const text = await res.text(); // any text payload
const blob = await res.blob(); // binary; feed it to a download link
const buf = await res.arrayBuffer(); // bytes for decoding yourself
const data = await res.json(); // JSON.parse on the body text
// stream a large body instead of buffering all of it
const reader = res.body.getReader();
const { value, done } = await reader.read();res.json()parses the body; it does not check that the endpoint really is a JSON one.res.statusis the code,res.statusTextthe reason phrase, andres.urlthe final URL after any redirects.res.redirectedtells you whether a redirect happened; the original request URL is not available on the response.- Streaming with
res.bodyis how progressive rendering and row-by-row CSV processing are built.
Status codes worth branching on
| Code | Meaning | What the client should do |
|---|---|---|
| 200 | OK | read the body |
| 201 | Created | read Location, refresh the list |
| 204 | No Content | do not try to parse a body |
| 400 | Bad Request | show validation errors from the body |
| 401 | Unauthorized | refresh the token or prompt for login |
| 403 | Forbidden | show a permission message |
| 404 | Not Found | render an empty state |
| 409 | Conflict | re-fetch and retry once |
| 429 | Too Many Requests | wait for Retry-After, then back off |
| 500, 502, 503 | Server error | retry with backoff and a cap |
async function request(url, init) {
const res = await fetch(url, init);
if (res.status === 204) return null;
if (res.status === 401) { await refresh(); return request(url, init); }
if (res.status === 429) {
const wait = Number(res.headers.get('retry-after') || 5);
throw new Error('rate limited, retry in ' + wait + 's');
}
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}⚠️
Retrying a non-idempotent request can duplicate data. Only replay
GET, PUT, DELETE and HEAD automatically, or make the write safe to repeat with a client-generated idempotency key.FAQ
Should I always send Content-Type?
Only when you send a body. Declaring
application/json while actually posting form data makes the server parse nothing, and sending the header with an empty body confuses some frameworks.Why is res.status 0?
The request never completed: a network failure, a blocked mixed-content request, or a fetch that was aborted. Treat 0 as 'no response' rather than as a server error.
Related
Requests with fetch HTTP status codes
Last refreshed 2026-09-18.