Fetch, JSON, and the Network in JavaScript

Published Updated

To fetch JSON in JavaScript, call fetch(url), check response.ok, and await response.json(). Fetch handles the HTTP request; parsing the body is a separate asynchronous step.

This is part of our asynchronous JavaScript guides. Start with the JavaScript overview if functions and objects are new to you.

You need Node installed and nothing else. fetch is built into current Node, so the standalone examples can run with node app.js from a folder containing a package.json with { "type": "module" }. Adapt placeholders such as url and API credentials before running a snippet. Browser examples also depend on CORS rules and may produce different error messages.

How Do You Fetch JSON in JavaScript?

Call fetch with the URL, then call json on its response to read the data. The GitHub API allows public repository requests without a key, subject to rate limits. This first example assumes a successful response; the next section adds the status check.

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.

Why Should You Check response.ok?

Check response.ok because fetch fulfills its promise even when the server returns an HTTP error such as 404. Ask for a repository that does not exist and inspect the response:

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.

Without a status check, your code may parse an error payload and treat it as the data it expected.

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.

How Do You Read a Fetch Response Body?

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 memorizing. 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 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. 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 capitalization.

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 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.

How Do You Send JSON with a POST Request?

To send JSON with fetch, set method: "POST", add an application/json content type, and use JSON.stringify for the body. Passing a plain object does not encode it as JSON.

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. 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].

How Do HTTP Errors Differ from Fetch Rejections?

An HTTP error means the server returned a response with a failure status. Fetch rejections happen when the request cannot complete normally, for example because of a network failure, cancellation, or a browser policy. Handle both cases.

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) {
    throw new Error(`HTTP error: ${response.status}`);
  }
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.log("request or response failed:", error.message);
}

How Do You Cancel a Fetch Request or Set a Timeout?

Use AbortSignal.timeout to give a fetch request a deadline, or pass an AbortController signal when your code needs to cancel it. A request without your own deadline can wait longer than you want.

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 JavaScript fetch not reject on a 404?

JavaScript fetch does not reject on a 404 because it received an HTTP response from the server. Check response.ok or response.status to handle HTTP errors. Network failures, cancellation, and browser policy errors can reject the fetch promise.

Do you need Axios when JavaScript has fetch?

You do not need Axios for ordinary requests because fetch is built into modern browsers and Node. Axios can still be useful for features such as interceptors and upload progress, depending on the application.

What does a CORS error mean for JavaScript fetch?

A CORS error means the browser has blocked JavaScript from accessing a cross-origin response under its security rules. The API server must allow the origin and request involved. Adding response headers to your request or using no-cors does not grant readable access to that response.

Should you validate JSON returned by JavaScript fetch?

Validate the JSON returned by fetch before relying on its fields. Parsing confirms valid JSON syntax, not the structure your application expects. Check the fields and types you need, especially before reading nested values.

Sources

  1. [1]
    Using the Fetch API
    (developer.mozilla.org)
  2. [2]
    Response
    (developer.mozilla.org)
  3. [3]
    AbortSignal
    (developer.mozilla.org)