Skip to content

05 · Working with JSON & Fetch API

JSON: JavaScript Object Notation

JSON is a text format for representing data — the universal language APIs use to send and receive structured data over HTTP.

const user = { name: "Ada", age: 30, tags: ["admin", "beta"] };

const json = JSON.stringify(user); // convert a JS value to a JSON string
console.log(json); // {"name":"Ada","age":30,"tags":["admin","beta"]}

const parsed = JSON.parse(json); // convert a JSON string back to a JS value
console.log(parsed.name); // Ada
console.log(parsed.tags[0]); // admin

JSON.stringify accepts extra arguments for pretty-printing and filtering keys — handy for logging or writing config files.

console.log(JSON.stringify(user, null, 2));
// {
//   "name": "Ada",
//   "age": 30,
//   "tags": [
//     "admin",
//     "beta"
//   ]
// }

console.log(JSON.stringify(user, ["name", "age"])); // only these keys
// {"name":"Ada","age":30}

What JSON can't represent

JSON only supports plain data — some JavaScript values are silently dropped or converted when stringified.

const tricky = {
  fn: () => "hi",       // functions are omitted entirely
  missing: undefined,    // undefined keys are omitted entirely
  when: new Date(2024, 0, 1), // Dates become ISO strings
  empty: null,           // null is preserved as-is
};

console.log(JSON.stringify(tricky));
// {"when":"2024-01-01T00:00:00.000Z","empty":null}

Handling malformed JSON

JSON.parse throws a SyntaxError on invalid input — always wrap it in try/catch when the data comes from outside your program (a network response, user input, a file).

function safeParseJSON(text) {
  try {
    return { data: JSON.parse(text), error: null };
  } catch (error) {
    return { data: null, error: error.message };
  }
}

console.log(safeParseJSON('{"ok": true}')); // { data: { ok: true }, error: null }
console.log(safeParseJSON("not json"));
// { data: null, error: 'Unexpected token o in JSON at position 1' }

The Fetch API

fetch makes an HTTP request and returns a Promise that resolves to a Response object once the headers arrive — the body itself is read separately and asynchronously, via its own methods.

async function getTodo(id) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  const todo = await response.json(); // parses the body as JSON, returns a Promise
  return todo;
}

getTodo(1).then((todo) => console.log(todo));
// { userId: 1, id: 1, title: 'delectus aut autem', completed: false }

Checking for HTTP errors

A key fetch gotcha: the returned Promise only rejects on network failure (DNS error, no connection). A 404 or 500 response still resolves successfully — you must check response.ok yourself.

async function getTodoSafely(id) {
  try {
    const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);

    if (!response.ok) {
      // response.ok is false for any status outside 200-299
      throw new Error(`request failed with status ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.log("could not load todo:", error.message);
    return null;
  }
}

getTodoSafely(999999).then((todo) => console.log(todo));

Sending data with fetch

For anything beyond a GET, pass an options object with the method, a JSON body, and the matching Content-Type header.

async function createPost(title, body) {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title, body, userId: 1 }),
  });

  if (!response.ok) {
    throw new Error(`create failed: ${response.status}`);
  }

  return response.json();
}

createPost("Hello", "World").then((created) => console.log(created.id));

Fetch in the browser vs. Node.js

Modern Node.js (18+) ships a global fetch that behaves the same as the browser's, so the same code generally runs in both environments unmodified.

Environment fetch availability Common gotchas
Browser global, built-in CORS restrictions on cross-origin requests
Node.js 18+ global, built-in no CORS concept, but no DOM either
Node.js < 18 not built-in needs a package like node-fetch

Fetch cheat sheet

Task Code
GET and parse JSON const data = await (await fetch(url)).json();
Check for HTTP errors if (!response.ok) throw new Error(response.status);
POST JSON fetch(url, { method: "POST", headers: {...}, body: JSON.stringify(data) })
Parse JSON string JSON.parse(text)
Serialize to JSON string JSON.stringify(value, null, 2)

How It Actually Works

fetch() returns a promise that resolves as soon as the headers arrive — not when the full body has downloaded. That's why response.ok/response.status are available immediately in your first .then, but reading the body via response.json() returns another promise: it has to keep reading from the underlying stream (backed by the network layer outside JS entirely) until all bytes arrive, then run the JSON parser on the accumulated text. JSON.parse itself is a recursive-descent parser implemented in C++ inside V8 (not JS) for speed — it builds the object graph in one pass rather than building a intermediate token stream first, which is part of why parsing huge JSON payloads can briefly block the main thread despite fetch itself being non-blocking.

fetch deliberately does not reject on HTTP error statuses like 404 or 500 — it only rejects on network-level failures (DNS failure, connection refused, CORS block). This is a common bug source: the promise resolving successfully just means "a response arrived," so checking response.ok yourself is mandatory. Under the hood, the actual network request is handled entirely outside the JS engine — by the browser's networking stack or, in Node, by the underlying HTTP client — running on separate OS threads; your JS thread is never blocked waiting on the socket, it's just waiting for a promise resolution to be queued as a microtask once the response event arrives from that other thread.

Exercise

Write an async function getUserPosts(userId) that fetches https://jsonplaceholder.typicode.com/posts?userId=<userId>, throws a descriptive error if the response isn't OK, and otherwise returns an array of just the post titles (use .map() on the parsed JSON).