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

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 real‑world 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 lower‑level 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:
404500403
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-Typeheader - 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 error‑handling 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 promise‑based
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 step‑by‑step code.
A Few Common Mistakes
The same fetch() mistakes appear again and again:
- forgetting to call
response.json() - assuming
fetch()throws automatically for404or500 - sending an object without
JSON.stringify() - forgetting the
Content-Typeheader 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 higher‑level 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:
- make a request with
fetch() - inspect the response
- decide whether the status is acceptable
- parse the body in the right format
- 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 aResponse, not parsed JSON.- Use
await response.json()to read a JSON body. - Check
response.okorresponse.statusbecause HTTP errors do not throw automatically. - Use
JSON.stringify()and the correctContent-Typeheader when sending JSON withPOST. - 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.