JavaScript Fetch API Tutorial: GET, POST, Error Handling and Timeouts
Fetch is the built-in way to call APIs from JavaScript, but it has one surprise that catches nearly everyone. Learn to send requests, handle every kind of failure, and build a small helper you can reuse.
fetch()returns a promise for aResponse, and reading the body takes a secondawait.- HTTP errors such as 404 and 500 do not reject, so always check
response.ok. - To send JSON, use
JSON.stringifyand set theContent-Typeheader. - Fetch has no default timeout option, so use
AbortSignal.timeout().
Most modern web apps talk to a server all the time. The JavaScript Fetch API is the standard way to do that, and it is built into browsers and into Node.js 18 and later, so there is nothing to install.
Basic use takes two lines, yet many tutorials stop there and leave out the part that causes real bugs: failure handling. We tested the examples in this guide against a small local server on Node.js 24, and the output shown comes from that run. example.com in the code stands in for your own API. If promises are new to you, begin with JavaScript promises explained.
What is the Fetch API?
Fetch is a promise-based interface for making HTTP requests. It replaced the older, callback-driven XMLHttpRequest, and many projects now use it rather than adding a library for simple calls.
| Feature | Fetch | Axios (library) |
|---|---|---|
| Installation | Built in | Added as a dependency |
| JSON body | Stringify it yourself | Automatic |
| Parse JSON response | Call response.json() |
Automatic |
| HTTP errors reject? | No | Yes, by default |
| Timeout | Through AbortSignal |
A timeout option |
A GET request
Fetch makes a GET request by default. There are two steps, and each one needs await: first the response headers arrive, and then you read the body.
const response = await fetch("https://example.com/users/1");
const user = await response.json();
console.log(user);
// { id: 1, name: 'Asha' }
The two steps exist because the body may be large and arrive later than the headers. In a browser script, top-level await needs a module, so you will usually put this inside an async function. See our JavaScript async/await guide.
The Response object
Before you read the body, you can inspect the response:
console.log(response.status, response.ok, response.headers.get("content-type"));
// 200 true application/json
| Property or method | Gives you |
|---|---|
response.ok |
true for statuses 200 to 299 |
response.status |
The status code, such as 404 |
response.headers.get(name) |
One response header |
response.json() |
The body parsed as JSON |
response.text() |
The body as a string |
response.blob() |
Binary data, such as an image |
A body can be read only once. Calling json() a second time failed in our test:
await res.json();
await res.json();
// TypeError: Body is unusable: Body has already been read
If you do need to read it twice, call response.clone() first.
The big surprise: a 404 does not throw
This is the most important thing in this article. We requested a user who does not exist:
const res = await fetch("https://example.com/users/999");
console.log(res.status, res.ok);
// 404 false
No exception was thrown. As far as fetch is concerned, the request worked: the server was reached and it answered. The promise rejects only when no usable response arrives at all, as with a network failure. So code like this has a hidden bug:
try {
const res = await fetch(url);
const data = await res.json(); // may be an error payload
showUser(data); // runs anyway for 404 and 500
} catch (error) {
showError(); // does not run for 404 or 500
}
Check our HTTP status codes cheat sheet to see what each code means.
A reusable helper
Do not repeat that check everywhere. Wrap it once:
async function getJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}
try {
await getJson("https://example.com/users/999");
} catch (error) {
console.log("caught:", error.message);
}
// caught: Request failed with status 404
Now HTTP errors and network errors both end up in the same catch. This small function is worth having in every project. A real version might also read an error message from the response body, and handle a 204 No Content response, which has no JSON to parse.
POST JSON data
Pass an options object as the second argument. For JSON, you must do two things yourself: convert the data to a string and set the header.
const created = await getJson("https://example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ben" }),
});
console.log(created);
// { id: 2, name: 'Ben', type: 'application/json' }
Our test server echoed back the content type it received, confirming the header arrived. Forgetting that header is a common cause of confusing server errors, because many frameworks only parse JSON when it is declared. To upload a form or files, pass a FormData object as the body, and do not set Content-Type, since the browser needs to add a boundary value itself.
Other methods and options
| Option | Example | Purpose |
|---|---|---|
method |
"PUT", "PATCH", "DELETE" |
The HTTP method |
headers |
{ Authorization: "Bearer ..." } |
Request headers |
body |
JSON.stringify(data) |
The request body |
signal |
AbortSignal.timeout(5000) |
Cancelling |
credentials |
"include" |
Sending cookies cross-origin |
cache |
"no-store" |
Browser cache behaviour |
Choosing between REST and other API styles? Our comparison of REST vs GraphQL shows how fetch fits with each.
Query strings
Do not build query strings by joining text, because special characters will break them. URLSearchParams handles the encoding for you:
const params = new URLSearchParams({ q: "red shoes", page: 2 });
console.log(params.toString());
// q=red+shoes&page=2
await getJson(`https://example.com/search?${params}`);
// { q: 'red shoes', page: '2' }
The server received red shoes correctly, with the space restored.
Timeouts and cancelling
Fetch will wait a long time on a slow server unless you tell it otherwise. AbortSignal.timeout() sets a limit. Our test endpoint took two seconds, against a limit of 200 milliseconds:
try {
await fetch("https://example.com/slow", { signal: AbortSignal.timeout(200) });
} catch (error) {
console.log(error.name);
}
// TimeoutError
To cancel manually, for example when a user leaves a page or types a new search term, use an AbortController:
const controller = new AbortController();
const pending = fetch("https://example.com/slow", { signal: controller.signal });
controller.abort();
try {
await pending;
} catch (error) {
console.log(error.name);
}
// AbortError
To run several requests at once, use Promise.all, which is much faster than awaiting them one at a time:
const [user, search] = await Promise.all([
getJson("https://example.com/users/1"),
getJson("https://example.com/search?q=a&page=1"),
]);
Every kind of failure
Here is what each failure looked like in our run, along with how to tell them apart:
| Situation | What happens | How to detect it |
|---|---|---|
| Server returns 404 or 500 | Promise fulfills | response.ok is false |
| Server cannot be reached | Rejects with TypeError |
catch |
| Timeout reached | Rejects with TimeoutError |
error.name |
| Manually cancelled | Rejects with AbortError |
error.name |
| Body is not valid JSON | json() rejects with SyntaxError |
catch |
That last row is common in production. A proxy returning a 502 often sends an HTML error page. We reproduced this, and res.json() threw a SyntaxError. Checking response.ok before parsing means you report the real problem rather than a confusing parse error.
Tip: Test these failure paths on purpose. Browser developer tools can simulate being offline, and test tools can mock a 500 response. Our API testing with Postman guide covers checking the API side.
CORS and credentials
In a browser, a request from one origin to another is subject to CORS, the cross-origin resource sharing rules. If you see an error mentioning Access-Control-Allow-Origin, the fix belongs on the server, which must allow your site. In your JavaScript, the failure shows up only as a generic TypeError, so look at the browser console for the actual reason. Node.js does not enforce CORS, so a request that works in a script can still fail in the browser.
By default, browsers do not send cookies on cross-origin requests. Set credentials: "include" if you need them, and make sure the server allows it. Never put secret API keys in browser JavaScript, since anyone can read them. Keep them on your server.
Common mistakes
- Not checking
response.ok. Error responses are treated as success. - Forgetting the second
await.response.json()returns a promise too. - Forgetting
JSON.stringify. Passing an object as the body sends[object Object]. - Setting
Content-TypewithFormData. This breaks the boundary, so leave it out. - Having no timeout. A hung request can leave a page’s spinner running indefinitely.
- Parsing JSON for an empty response. A 204 has no body, and
json()will throw.
Final thoughts
Fetch is simple once you remember its main rule: a response is not the same as a success. Copy the small helper into your project, add a timeout and test your failure paths. As a next step, build a tiny page that loads data from an API, shows a clear error message and lets the user cancel a slow search.
Frequently asked questions
Does fetch throw an error on a 404?
No. fetch only rejects when no response is received, such as on a network failure. For HTTP errors, check response.ok or response.status yourself.
How do I send JSON with fetch?
Set method to POST, add a Content-Type header of application/json, and pass the data through JSON.stringify as the body.
Why do I need two awaits with fetch?
The first resolves once the response headers arrive. The second waits for the body to download and be parsed, which can happen later.
How do I set a timeout on fetch?
Pass signal: AbortSignal.timeout(5000) in the options. The request then rejects with a TimeoutError if it takes longer than that many milliseconds.
Is fetch available in Node.js?
Yes. Fetch is available globally in Node.js 18 and later. Older versions need a package, though CORS rules apply only in browsers.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.