· By John Kavanagh

The Fetch API for Beginners: GET, POST, JSON, and Errors

Abstract image used to represent Fetch API for Beginners: GET, POST, JSON, Errors
Image by Gabriel Soto.

fetch() looks simple enough that beginners often underestimate where the awkward parts are.

Inside an async function, the request can start with one line:

const response = await fetch('/api/products');

That makes it tempting to assume the rest will behave naturally as well. Then the first realworld problems show up:

  • the response is not automatically JSON
  • HTTP errors do not throw in the way people expect
  • request bodies need converting
  • headers matter

None of this makes fetch() bad. It just means it is a lowerlevel API than many people first assume.


A basic GET request

The simplest use of fetch() is a GET request. The typed examples in this article use TypeScript, which compiles to JavaScript:

const loadProducts = async (): Promise<void> => {
  const response = await fetch('/api/products');
  const products = await response.json();

  console.log(products);
};

Calling fetch() sends the request and returns a promise. In this example, await waits for that promise to fulfil with a Response object.

That Response object is not the JSON data itself. It is a wrapper around the HTTP response, which means you still need to read the body in the format you expect.


Reading JSON Requires an Extra Step

This is one of the first places beginners go wrong.

fetch() does not automatically parse JSON for you. You need to call:

await response.json();

That is a separate asynchronous step because the response body still needs to be consumed and parsed.

If the response body is not valid JSON, that parsing step can fail even if the network request itself technically succeeded.


fetch() does not throw on HTTP errors by default

This is the single most important beginner gotcha.

If the server responds with:

  • 404
  • 500
  • 403

fetch() does not automatically reject the promise just because the HTTP status is an error.

You still get a Response object. It is up to you to inspect the status.

Inside an async function, check the response before using its body:

const response = await fetch('/api/products');

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

response.ok is a convenient boolean that is true for successful status codes in the 200 range.

Without this kind of check, beginners often assume the request "worked" because no exception was thrown, even though the server actually returned an error page or failure payload.


A safer GET example

Here is a more careful TypeScript helper. It checks both the HTTP status and the fields the application expects in each product:

type Product = {
  id: number;
  name: string;
};

const loadProducts = async (): Promise<Product[]> => {
  const response = await fetch('/api/products');

  if (!response.ok) {
    throw new Error(`Failed to load products: ${response.status}`);
  }

  const products = await response.json();
  if (
    !Array.isArray(products) ||
    !products.every((product) =>
      product !== null &&
      typeof product === 'object' &&
      typeof product.id === 'number' &&
      Number.isFinite(product.id) &&
      typeof product.name === 'string'
    )
  ) {
    throw new Error('Invalid product response');
  }

  return products;
};

A TypeScript assertion such as as Promise<Product[]> would not validate the response. Types are erased before this code runs. The checks above establish that the parsed value is an array and that every item has a finite numeric id and a string name; add any further rules the application requires.


Sending JSON with POST

When sending data, you usually need more than the URL.

For a JSON POST request, you typically provide:

  • the method
  • the headers
  • the body

For example:

const saveProduct = async (): Promise<void> => {
  const response = await fetch('/api/products', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'Notebook',
      price: 12.99,
    }),
  });

  if (!response.ok) {
    throw new Error(`Failed to save product: ${response.status}`);
  }
};

The important part here is that the request body is JSON text, not a raw object. That is why JSON.stringify() is needed.


The Request Body is Not Magically Converted

This is the same basic lesson as with localStorage and JSON: objects and JSON text are not the same thing.

If you try to send a plain object directly as the body of a JSON request, you are likely to get the wrong result.

So when the server expects JSON:

  • set the Content-Type header
  • convert the body with JSON.stringify()

Those two steps belong together.


Network Failure and HTTP Failure are Different

This is another distinction beginners need early.

A network failure is when the request cannot complete properly at the network layer at all. In that case, fetch() will reject.

An HTTP failure is when the server responds with an error status such as 404 or 500. In that case, fetch() still resolves with a Response; you must check response.ok or response.status.

That means a sensible errorhandling pattern often needs both:

const loadProducts = async (): Promise<void> => {
  try {
    const response = await fetch('/api/products');

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

    const products = await response.json();
    console.log(products);
  } catch (error) {
    console.error('Request failed', error);
  }
};

This catches both types of problem more clearly.


fetch() is promisebased

fetch() returns a promise, which is why await fits it so naturally.

You can use .then() as well:

fetch('/api/products')
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    return response.json();
  })
  .then((products) => {
    console.log(products);
  })
  .catch((error) => {
    console.error(error);
  });

But for beginners, async / await often makes the request flow easier to read because it looks more like straightforward stepbystep code.


A Few Common Mistakes

The same fetch() mistakes appear again and again:

  • forgetting to call response.json()
  • assuming fetch() throws automatically for 404 or 500
  • sending an object without JSON.stringify()
  • forgetting the Content-Type header for JSON requests
  • trying to read the same response body twice

Most of those come from the same root cause: treating fetch() as if it were a higherlevel convenience API rather than a fairly direct interface to the request and response flow.


Keep the Shape of the Problem in Mind

The clean mental model is:

  1. make a request with fetch()
  2. inspect the response
  3. decide whether the status is acceptable
  4. parse the body in the right format
  5. handle both network and HTTP failure cases deliberately

That is a much stronger foundation than memorising one short example and hoping it covers everything.


Wrapping Up

The Fetch API returns a promise for a Response, not immediate JSON. Read and validate the body separately, check HTTP errors with response.ok, and handle network failures too. Keeping those steps explicit makes the request easier to reason about.

Key Takeaways

  • fetch() returns a promise that fulfils with a Response, not parsed JSON.
  • Use await response.json() to read a JSON body.
  • Check response.ok or response.status because HTTP errors do not throw automatically.
  • Use JSON.stringify() and the correct Content-Type header when sending JSON with POST.
  • Handle network failure and HTTP failure as related but different problems.

Once you stop expecting fetch() to make every decision for you, it becomes a very straightforward API to work with.


Untangling a delivery problem?

Send the symptoms, constraints, and affected routes. I'll help identify whether the issue sits in the application, platform, content model, deployment path, or search surface.