Fetch, JSON, and the Network in JavaScript
By the end of this page you will be able to request data from an API, tell a server error apart from a network failure, send data back, and stop a request that is taking too long.
You need Node installed and nothing else. fetch is built into current Node, so every example here runs with node app.js from a folder containing a package.json with { "type": "module" }. The same code is valid in a browser console, though CORS policy and different error wording can change what you see there.
Your First Fetch
The GitHub API needs no key for public data, which makes it a good first target. This asks for one repository and prints three fields:
const response = await fetch("https://api.github.com/repos/nodejs/node");
const data = await response.json();
console.log(data.full_name);
console.log(data.default_branch);
console.log(data.stargazers_count); // node output, the star count changes daily:
// nodejs/node
// main
// 118828 Two awaits, and they are waiting for different things. The first waits for the response headers to arrive, and the second waits for the body to finish downloading and be parsed.
That split is why response is not your data. It is an object describing the answer, and you have to ask it for the body separately.
The Check Almost Every Tutorial Skips
Ask for a repository that does not exist and watch what does not happen:
const response = await fetch("https://api.github.com/repos/nodejs/not-a-real-repo");
console.log("ok:", response.ok);
console.log("status:", response.status);
console.log("statusText:", response.statusText); // node output:
// ok: false
// status: 404
// statusText: Not Found No error was thrown. The request worked perfectly and the answer was a refusal, which from the network's point of view is a completely normal outcome.
This is the single most common way a fetch bug hides. Without a check, the next line calls .json() on an error payload and your code carries on holding an object that is not what it thinks it is.
The ok property is true only for statuses in the 200 to 299 range. Test it before touching the body:
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
try {
await getJson("https://api.github.com/repos/nodejs/not-a-real-repo");
} catch (error) {
console.log(error.message);
} // node output:
// Request failed: 404 Not Found Write that helper once and reach for it by default. Turning an unwanted status into a thrown error is what puts network failures back into the ordinary try and catch handling covered in the promises guide.
It is a starting point rather than a universal one. A 204 is ok and has no body at all, and plenty of successful responses are not JSON, so a helper used everywhere eventually has to check the status and the content type too.
Turning a Response into Data
The .json() method reads the whole body and parses it. It fails loudly when the body is not JSON, which happens more often than you would expect, because error pages are usually HTML:
const response = await fetch("https://example.com/");
console.log(response.status, response.headers.get("content-type"));
await response.json(); // node output, stack trace trimmed:
// 200 text/html
// SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON That message is worth memorising. A parse error mentioning an unexpected < nearly always means you fetched an HTML page, usually a login redirect, an error page, or a wrong URL.
When you are diagnosing one, reach for .text() instead and print what actually arrived. There is one catch: each body can only be read once, so reading it twice fails.
const response = await fetch("https://api.github.com/repos/nodejs/not-a-real-repo");
const body = await response.text();
console.log(body.slice(0, 40));
await response.json(); // node output, stack trace trimmed:
// {"message":"Not Found","documentation_ur
// TypeError: Body is unusable: Body has already been read You are not forced to choose, though. Take a copy with response.clone() before reading either one, and each copy carries its own body:
const response = await fetch("https://api.github.com/repos/nodejs/not-a-real-repo");
const copy = response.clone();
console.log((await copy.text()).slice(0, 40));
console.log((await response.json()).message); // node output:
// {"message":"Not Found","documentation_ur
// Not Found The simpler alternative is to read the text once and parse it yourself with JSON.parse, which leaves you holding the raw body for the error message when parsing fails.
Query Parameters and Headers
Most GET requests need options, and those go in the URL. Building that string by hand breaks the moment a value contains a character the URL itself uses, such as &, =, +, or #, so let URLSearchParams do the escaping:
const params = new URLSearchParams({ limit: "2", skip: "10" });
const response = await fetch(`https://dummyjson.com/todos?${params}`);
console.log(response.url);
console.log(response.status, response.headers.get("content-type"));
const data = await response.json();
console.log(data.todos.map((todo) => todo.todo)); // node output, observed on the day of writing.
// This is a shared demo API, so the rows you get back will differ:
// https://dummyjson.com/todos?limit=2&skip=10
// 200 application/json; charset=utf-8
// [ 'a todo string', 'another todo string' ] Every value is escaped on the way in, so a search term with spaces or punctuation survives the trip. Reading a header back uses get with the name, and the lookup ignores capitalisation.
Treat the rows themselves as illustration. That endpoint is a shared demo API whose contents change, which is a good habit to carry to any API you did not write.
The response.url line is worth a habit of its own. It reports where the answer actually came from, which is not the address you asked for once a redirect has been followed.
Headers you send go in the second argument, and they are how an API learns who you are and what you want back. The two you meet first are Accept, naming the format you want, and Authorization, carrying a key or a token.
Browser-side, cookies follow a rule of their own. The default is credentials: "same-origin", meaning cookies go only to the same scheme, host, and port that the page came from.
The other two settings are omit, which never sends them, and include, which allows them cross-origin. Even then the cookie's own attributes and the browser's third-party cookie policy still apply, and the server has to return explicit origin and credentials headers before your script may read the response.
That rule is behind a whole family of confusing bugs, where a logged-in page gets a 401 from an API on a different subdomain. Node's built-in fetch keeps no cookie jar of its own, so the same script authenticates differently in the two places unless you set the header yourself.
Sending Data with POST
Sending JSON takes three extra pieces: a method, a header saying what the body is, and the body itself as a string. A plain object cannot be a body, so JSON.stringify does the conversion.
JSON is not the only option. A body can also be FormData, URLSearchParams, a blob, or plain text, and some of those set the content type for you.
Writing to a real API usually needs a key, so this example uses a public demo API that simulates the write and answers with the record it would have created:
const response = await fetch("https://dummyjson.com/todos/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
todo: "Send the weekly report",
completed: false,
userId: 1,
}),
});
console.log(response.status, response.ok);
console.log(await response.json()); // node output, observed once. The generated id varies,
// and nothing is really saved:
// 201 true
// {
// id: <a generated number>,
// todo: 'Send the weekly report',
// completed: false,
// userId: 1
// } The 201 status means created, and it counts as ok. An API that needs to know who you are usually wants one more header, most often Authorization holding a key or a token, though cookies and signed URLs are common too.
Keep that key on a server. Anything in front-end JavaScript is visible to anyone who opens the browser tools, so a key shipped to the page is a key you have published.
Two details catch people out. Forgetting the Content-Type header makes many servers reject the body as unreadable, and passing an object instead of a string sends the useless text [object Object].
Two Kinds of Failure
You have seen the first kind: the server answered, and the answer was bad. The second kind is when no usable answer reaches your code, and that is the kind that rejects:
try {
await fetch("https://this-host-does-not-exist.example/data");
} catch (error) {
console.log(error.constructor.name);
console.log(error.message);
console.log("cause:", error.cause?.code);
} // node output:
// TypeError
// fetch failed
// cause: ENOTFOUND The message is famously unhelpful. In Node the detail lives in error.cause, so log that too: ENOTFOUND means the hostname did not resolve, and ECONNREFUSED means nothing was listening.
Browsers word this differently and often give you a bare TypeError with nothing attached. The standard promises the TypeError, not the message or the code.
In a browser the same rejection covers being offline, a blocked request, and a CORS refusal. Real code needs both guards, because the two families arrive through different doors:
try {
const response = await fetch(url);
if (!response.ok) {
console.log("the server refused:", response.status);
return;
}
const data = await response.json();
console.log(data);
} catch (error) {
console.log("no usable response reached the code:", error.message);
} Timeouts and Cancelling a Request
A request with no deadline of your own can wait far longer than your product should. Something else will eventually give up, whether the browser, Node, a proxy, or the server, but not on your schedule. AbortSignal.timeout is the shortest fix:
try {
await fetch("https://api.github.com/repos/nodejs/node", {
signal: AbortSignal.timeout(1),
});
} catch (error) {
console.log(error.name);
console.log(error.message);
} // node output:
// TimeoutError
// The operation was aborted due to timeout The timeout is set to one millisecond so a timeout is all but certain. Use something realistic, such as 8000, in real code.
When you need to cancel on purpose rather than on a clock, create an AbortController and keep it. Calling abort stops your side of the request wherever it has got to, though the server may already have done the work:
const controller = new AbortController();
setTimeout(() => controller.abort(), 10);
try {
await fetch("https://api.github.com/repos/nodejs/node", {
signal: controller.signal,
});
} catch (error) {
console.log(error.name, "|", error.message);
} // node output:
// AbortError | This operation was aborted Check for error.name being AbortError in your catch block and stay quiet about it. A cancellation you asked for is not a failure the reader should see.
Browser-side, this is also how you cancel a request whose result nobody wants any more, such as a search whose box has changed since. The timers and races guide covers that pattern in full.
Pitfalls and Debugging
SyntaxError: Unexpected token <. The body was HTML, not JSON. Print response.status and response.headers.get("content-type") before parsing, and read the URL again.
TypeError: fetch failed. Node's wording for a request that produced no usable response. Log error.cause for the real reason, then check the hostname, the port, and whether the server is running. A browser words this differently and may tell you far less.
The data is undefined and no error appeared. The field you read does not exist on the object you got. Log the whole parsed body once before reaching into it, since a parsed response is only as correct as the request that produced it.
Blocked by CORS policy. The server did not permit your page to read the response. This is not fixable in front-end code, so route the call through a server you control, or use an API that allows browser requests.
A POST that arrives empty. The two most common causes are a body that was an object rather than a string, and a missing Content-Type header. Routing, middleware, proxies, and size limits can produce the same symptom, so check the request in the Network tab before rewriting the code.
Everything works locally and fails on the deployed site. Usually a URL. A relative path such as /api/notes resolves against whatever domain the page is on, which is rarely the same in both places.
The request is fine but you cannot see it. Open the browser tools, choose the Network tab, and reload. It shows the URL, the status, the headers sent, and the exact body returned, which settles most arguments in seconds.
Frequently Asked Questions
Why does fetch not throw on a 404?
Because the request succeeded. The browser asked, the server answered, and the answer was a 404 page. An HTTP error status on its own never rejects; fetch rejects for network failures, aborts and timeouts, bad request options, and browser policy refusals such as CORS. That design is why checking response.ok yourself is not optional.
Do you still need axios?
Not for ordinary requests. Fetch is built into every current browser and into Node, so a plain project needs no dependency at all. Libraries still earn their place on larger apps for interceptors and upload progress, which fetch does not provide. Retries are usually a plugin rather than a default, in axios included.
What is a CORS error and can you fix it in your code?
It means the server did not give the browser permission to share its response with your page. You cannot grant yourself access by adding an Access-Control-Allow header to the request. Removing a custom request header sometimes avoids a preflight; otherwise the fix belongs to the API owner, or to a small server of your own.
Should you trust the shape of the JSON you get back?
No. A parsed response is whatever the server sent, and reading a field that is not there gives you undefined rather than an error, which surfaces much later as a confusing bug. Check the fields you depend on, or validate the whole object before using it.
Related
- JavaScript Async for the full topic overview
- Promises and async/await for the promise machinery every example here uses
- Timers, Ordering, and Race Conditions for what happens when two requests overlap
- Objects, Arrays, and Destructuring for reading the objects a parsed response gives you
- unknown, any, and Runtime Data for typing a response you did not write
- JavaScript for the language guide and the wider learning path
Sources
-
[1]
Using the Fetch API(developer.mozilla.org)
-
[2]
Response(developer.mozilla.org)
-
[3]
AbortSignal(developer.mozilla.org)
Read Next
setTimeout and setInterval, the order callbacks actually print in, the stale response race that breaks search boxes, debounce and throttle, and cleaning up when the owner disappears.
What a promise is and the three states it can be in, then and catch, the async and await keywords, waiting on overlapping work with Promise.all, and where a thrown error ends up.
The JavaScript that runs later: promises and await, fetching data over the network, and the ordering rules behind timers and race conditions.