The Fetch API is one of the first tools JavaScript developers reach for when making API requests in web apps, but the basic examples often stop right before the hard parts begin. This guide explains how to use Fetch API with async await in a way that holds up in real projects: checking status codes, parsing responses safely, sending JSON, handling timeouts, canceling stale requests, and building small helper functions you can reuse. If you have ever written await fetch(...) and then wondered why your error handling still feels unreliable, this is the pattern guide to keep nearby.
Overview
At a glance, fetch gives you a promise-based way to make HTTP requests in the browser and in many modern JavaScript runtimes. Combined with async/await, it becomes much easier to read than chained .then() calls.
A minimal request looks like this:
async function loadUsers() {
const response = await fetch('/api/users');
const data = await response.json();
return data;
}That is the part most tutorials show. The important detail they often skip is this: fetch only rejects the promise for network-level failures, aborted requests, or similar low-level problems. It does not throw automatically for HTTP errors like 404 or 500.
That means this code can still continue into response.json() even when the server returned an error status. In practice, good fetch api error handling means you should treat the request in two steps:
- Wait for the HTTP response.
- Check whether the response was successful before using the body.
If you remember just one rule from this article, make it this one: always inspect response.ok or response.status before assuming the request succeeded.
Here are the core pieces you will use often:
await fetch(url, options)to send the requestresponse.okto check for a 2xx resultresponse.statusfor the exact HTTP status coderesponse.json(),response.text(), orresponse.blob()to read the bodytry/catchfor network failures and thrown errors
If you want to go deeper into how frontend and backend responsibilities meet at the API boundary, it also helps to understand basic API design expectations. A related reference is REST API Design Best Practices Checklist for New Projects.
Core framework
A durable way to use fetch api async await is to standardize the flow of every request. You do not need a large abstraction layer. You just need a predictable sequence.
1. Start with a safe GET pattern
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}This is already better than the minimal version because it turns HTTP failures into thrown errors you can handle consistently.
Usage:
async function showUsers() {
try {
const users = await getJson('/api/users');
console.log(users);
} catch (error) {
console.error('Could not load users:', error.message);
}
}2. Separate transport errors from application errors
In real apps, two kinds of failures are easy to mix together:
- Transport errors: the browser could not complete the request at all
- Application errors: the server responded, but with an error status or an error payload
A slightly stronger helper makes that distinction clearer:
async function requestJson(url, options = {}) {
let response;
try {
response = await fetch(url, options);
} catch (error) {
throw new Error('Network error or request was blocked/aborted');
}
let data;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
const message = data?.message || `HTTP ${response.status}`;
throw new Error(message);
}
return data;
}This is useful because not every endpoint returns JSON in every situation. Error pages, reverse proxies, and older services sometimes return plain text or HTML. If your code assumes JSON every time, a failing request can become harder to debug than it needs to be.
3. Send JSON correctly
For POST, PUT, or PATCH requests, the usual pattern is to stringify the body and set the content type header:
async function createUser(user) {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(user)
});
if (!response.ok) {
throw new Error(`Create failed: ${response.status}`);
}
return response.json();
}Common use cases include:
- submitting a form to your backend
- creating a record in a database-backed API
- updating profile settings
- sending filter or search payloads
4. Keep request helpers small
One of the easiest mistakes in api requests in javascript is building a wrapper so abstract that no one trusts it. Prefer a simple base helper and a few explicit functions on top of it.
async function api(url, options = {}) {
const response = await fetch(url, options);
const isJson = (response.headers.get('content-type') || '')
.includes('application/json');
const body = isJson ? await response.json() : await response.text();
if (!response.ok) {
throw new Error(body?.message || `Request failed: ${response.status}`);
}
return body;
}
const apiGet = (url) => api(url);
const apiPost = (url, payload) =>
api(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});This keeps your application code readable while still centralizing the repetitive parts.
5. Know when to use async await directly
Not every call needs a helper. If a request is one-off and easy to read in context, inline code can be clearer:
async function deleteItem(id) {
try {
const response = await fetch(`/api/items/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Delete failed: ${response.status}`);
}
} catch (error) {
console.error(error);
}
}A good rule is to abstract repeated behavior, not every request.
Practical examples
The easiest way to build confidence with javascript fetch examples is to practice the patterns you will actually reuse. The cases below show up in dashboards, admin tools, portfolio projects, and production apps.
GET request with query parameters
async function searchUsers(term) {
const params = new URLSearchParams({ q: term });
const response = await fetch(`/api/users/search?${params}`);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
return response.json();
}URLSearchParams is better than hand-building query strings because it handles encoding safely.
POST request from a form
async function submitSignupForm(event) {
event.preventDefault();
const formData = new FormData(event.target);
const payload = Object.fromEntries(formData.entries());
try {
const user = await apiPost('/api/signup', payload);
console.log('Created user:', user);
} catch (error) {
console.error('Signup failed:', error.message);
}
}This pattern is a clean bridge between browser forms and JSON APIs.
Handling empty responses
Some successful requests return no response body, such as 204 No Content. If you always call response.json(), that can cause unnecessary errors.
async function updateStatus(id, status) {
const response = await fetch(`/api/tasks/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!response.ok) {
throw new Error(`Update failed: ${response.status}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}Timeouts with AbortController
By default, fetch does not include a built-in timeout option that behaves the way many developers expect. A common pattern is to use AbortController.
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timed out');
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}This is especially useful in UI flows where users need prompt feedback.
Canceling stale search requests
Live search is a classic source of race conditions. A slow earlier request can arrive after a faster later request and overwrite newer results.
let currentController;
async function searchProducts(term) {
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/products?q=${encodeURIComponent(term)}`, {
signal: currentController.signal
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
return null;
}
throw error;
}
}If you work on frontend UI behavior regularly, this kind of async control is worth revisiting alongside topics like closures and event handling. A useful companion read is JavaScript Interview Questions by Topic: Arrays, Closures, Async, and DOM.
Authenticated requests
Many applications need an authorization header:
async function getProfile(token) {
const response = await fetch('/api/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Auth request failed: ${response.status}`);
}
return response.json();
}If your app works with token-based auth, it helps to understand token structure and debugging basics. See JWT Decoder Guide: How to Read, Validate, and Troubleshoot Tokens Safely.
Parallel requests with Promise.all
When requests are independent, run them in parallel rather than one after another:
async function loadDashboard() {
try {
const [users, projects, alerts] = await Promise.all([
getJson('/api/users'),
getJson('/api/projects'),
getJson('/api/alerts')
]);
return { users, projects, alerts };
} catch (error) {
console.error('Dashboard load failed:', error.message);
}
}This can reduce waiting time in UIs where multiple panels need data at once.
Sequential requests when one depends on another
async function loadUserAndPosts(userId) {
const user = await getJson(`/api/users/${userId}`);
const posts = await getJson(`/api/users/${user.id}/posts`);
return { user, posts };
}Use sequence when the second request depends on data from the first. Do not force parallelism where it does not fit.
Common mistakes
Most fetch bugs are not caused by the API itself. They come from a few repeated assumptions. If you know these ahead of time, debugging becomes much faster.
Assuming fetch throws on 404 or 500
This is the most common misunderstanding. fetch resolves normally for HTTP error responses. You must check response.ok yourself.
Parsing every response as JSON
Some endpoints return plain text, some return no body, and some return HTML error pages. If you always call response.json(), your parsing code can fail in ways that hide the original issue.
Forgetting headers on JSON requests
If you send a JavaScript object directly instead of a stringified body, or forget the Content-Type: application/json header when your server expects it, the backend may reject the request or parse it incorrectly.
Ignoring loading and error state in the UI
Even when the network code is correct, the user experience can still feel broken if your interface does not represent loading, success, and failure clearly. A simple trio of states often goes a long way:
- loading while the request is in flight
- error if it fails
- data when it succeeds
Swallowing errors in a generic wrapper
It is tempting to catch every error and return null or an empty array. That can make the application appear stable while quietly hiding failures. Prefer returning useful errors unless the caller explicitly wants a fallback.
Not distinguishing retryable failures
Some failures are temporary, such as intermittent network issues. Others are not, such as invalid credentials or bad request payloads. Even if you do not build automatic retries, your code should leave room for different handling paths.
Overusing one global helper for every case
A single helper can reduce repetition, but when it becomes too clever, developers stop understanding what happens at the call site. Keep the abstraction narrow and the request intent visible.
If you are moving from JavaScript into a stricter codebase, adding typed request and response shapes can make these mistakes easier to catch earlier. A helpful next step is TypeScript for JavaScript Developers: A Practical Migration Guide.
When to revisit
The best time to revisit your fetch patterns is not only when something breaks. It is whenever the surrounding constraints change. Use this checklist as a practical maintenance trigger.
Revisit when your API contracts change
If endpoints start returning different shapes, new status codes, or empty bodies, your helper functions may need small updates. This is especially true when backend and frontend evolve separately.
Revisit when authentication changes
If your app moves from cookie-based auth to bearer tokens, or starts refreshing tokens automatically, your request flow often needs a deliberate redesign rather than quick patches.
Revisit when your app adds live search, polling, or background refresh
These features raise the importance of cancellation, race-condition prevention, and state management. Patterns that felt optional in a basic CRUD app become necessary.
Revisit when you move across environments
A small browser-only project may not need much abstraction. A larger app spanning browser code, server rendering, edge runtimes, or testing environments may benefit from shared wrappers and stricter response handling.
Revisit when debugging starts taking too long
If developers keep asking the same questions—did the server respond, was it JSON, did we check the status, was the request aborted—your fetch layer probably needs clearer conventions.
A practical action list
Before you leave this article, consider applying these five improvements to your current codebase:
- Create one small
getJsonorapihelper that checksresponse.ok. - Handle non-JSON or empty responses intentionally.
- Add
AbortControllerfor time-sensitive UI interactions. - Use
URLSearchParamsfor query strings instead of manual concatenation. - Audit every request path for loading, success, and error state.
If you are still building your broader JavaScript and web development foundations, it helps to place fetch in the bigger picture of frontend and backend skills. Two useful next reads are Frontend Developer Roadmap: What to Learn First and What to Skip and Backend Developer Roadmap: Skills, Projects, and Tools to Learn.
The durable takeaway is simple: learning how to use fetch api is not about memorizing one syntax snippet. It is about adopting a few request patterns that make success and failure equally clear. Once those patterns are in place, your API code becomes easier to read, easier to debug, and easier to trust the next time you come back to it.