Making an API call in JavaScript was simple in 2014, complicated in 2018, and is finally simple again in 2026. Most tutorials are still stuck in 2023 — fetch + Axios + XMLHttpRequest + jQuery as if those are the four equal options. They’re not.
In 2026 the real comparison is fetch with async/await and AbortController (the modern baseline that handles 95% of cases), Axios (still popular for interceptors and request/response transformations), TanStack Query (the React standard that handles caching, refetching, and cancellation for you), and ky / ofetch (lightweight modern wrappers that replace Axios for many projects). XMLHttpRequest and jQuery AJAX exist but are legacy footnotes — covered at the end for completeness.
This guide updates the classic “4 ways to make an API call” framing for 2026: each method gets the modern pattern (async/await, AbortController, proper error handling), plus the gotchas almost every guide skips — fetch doesn’t reject on 4xx/5xx, Axios CancelToken is deprecated, and 60% of JavaScript devs in 2026 use React Query for production apps.
Related: Promise.all vs allSettled vs race vs any · AbortController: Cancel Fetch Properly · Async/Await Not Working · JavaScript Event Loop Explained
What is an API Call?
An API call is when your JavaScript code sends a request to a server (REST endpoint, GraphQL, internal service) and waits for a response. APIs (Application Programming Interfaces) provide a standardized way for software systems to communicate. JavaScript runs in the browser (or Node, Bun, Deno) and uses HTTP under the hood — GET to read, POST to create, PUT/PATCH to update, DELETE to remove.
What you choose to use for that HTTP request — fetch, Axios, TanStack Query, or anything else — depends on your app’s complexity, framework, and what you need beyond a single request: caching, retries, cancellation, type safety, streaming, real-time updates.
Method 1 — fetch with async/await (The Modern Baseline)
JavaScript API call fetch with async/await is the modern baseline. Built into every browser since 2017, Node 18+, Bun, Deno, and Cloudflare Workers. No library to install, no bundle size.
JavaScript API Call Example — GET
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
// ⚠ fetch does NOT reject on 4xx/5xx — you must check yourself
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
// Usage
try {
const user = await getUser(42);
console.log(user);
} catch (err) {
console.error('Failed to load user:', err);
}
Fetch Error Handling 4xx — The Gotcha Almost Everyone Misses
fetch only rejects the promise on network failures. A 404, 401, 500 — fetch happily resolves with response.ok === false. This is the single most common fetch bug. Always check response.ok:
// ❌ Bug — this never throws on 404 or 500
const data = await fetch('/api/users/999').then(r => r.json());
// ✅ Correct — check response.ok first
const response = await fetch('/api/users/999');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
JavaScript API Call POST JSON
async function createUser(user) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(user),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(errorBody.message || `HTTP ${response.status}`);
}
return response.json();
}
// Usage
const newUser = await createUser({ name: 'Ada', email: '[email protected]' });
JavaScript API Call with Headers (Auth, CORS, Custom)
const response = await fetch('https://api.example.com/secure', {
headers: {
'Authorization': `Bearer ${token}`,
'X-Request-ID': crypto.randomUUID(),
'Accept': 'application/json',
},
credentials: 'include', // send cookies for cross-origin
});
Common header options:
credentials: 'include'— send cookies cross-origin (CORS-tricky; the server mustAccess-Control-Allow-Credentials: true)mode: 'cors'— default for cross-origin;'no-cors'is rarely useful (response opaque)cache: 'no-store'— bypass HTTP cache for this requestredirect: 'manual'— don’t follow 3xx redirects automatically
AbortController Fetch Timeout
fetch has no built-in timeout. The 2026 pattern is AbortSignal.timeout(ms):
async function fetchWithTimeout(url, timeoutMs = 5000) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
} catch (err) {
if (err.name === 'TimeoutError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
}
}
For the complete cancellation story — debounced searches, AbortSignal.any([...]) composition, the single-use trap — see the AbortController guide.
Fetch FormData Upload (File + Fields)
async function uploadFile(file, metadata) {
const formData = new FormData();
formData.append('file', file);
formData.append('title', metadata.title);
formData.append('description', metadata.description);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// DO NOT set Content-Type manually — browser sets multipart boundary automatically
signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
return response.json();
}
Critical gotcha: never set Content-Type manually for FormData. The browser needs to generate the multipart boundary string (multipart/form-data; boundary=...); manual Content-Type breaks the request.
Fetch Streaming Response
For large responses or server-sent updates, read the body as a stream instead of buffering it all into memory:
async function streamResponse(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
const chunk = decoder.decode(value, { stream: true });
console.log('Chunk:', chunk);
}
console.log(`Done — ${received} bytes total`);
}
Use this for AI streaming responses, large file downloads, log tailing — anything you want to process as it arrives rather than after it all arrives.
Type-Safe Fetch with Zod
Type the API response at runtime so a backend schema change doesn’t silently break your code:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return UserSchema.parse(data); // throws if shape is wrong
}
Zod.parse() throws a descriptive error when the response shape doesn’t match — better than a TypeError: cannot read property 'email' of undefined deep in your render code.
Method 2 — Axios (Still Popular for Interceptors)
Axios is the most popular JavaScript HTTP client beyond native fetch (about 60% of professional JS devs in the 2026 State of JS survey use it for at least one project). Its strengths: automatic JSON parsing, request/response interceptors, automatic 4xx/5xx rejection, and a single API for browser + Node.
import axios from 'axios';
// GET — auto-parses JSON, auto-rejects on 4xx/5xx
const { data: user } = await axios.get('/api/users/42');
// POST — body and headers in one config
const { data: newUser } = await axios.post('/api/users', { name: 'Ada' }, {
headers: { Authorization: `Bearer ${token}` },
});
// Interceptors — transform every request/response
axios.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
return config;
});
Axios Cancellation — CancelToken is Deprecated
Since Axios 0.22 (2021), CancelToken is deprecated in favor of the standard AbortController:
// ❌ Deprecated — don't write new code with this
const source = axios.CancelToken.source();
axios.get('/api/data', { cancelToken: source.token });
source.cancel();
// ✅ Modern — use AbortController like with fetch
const controller = new AbortController();
axios.get('/api/data', { signal: controller.signal });
controller.abort();
// ✅ With AbortSignal.timeout for timeouts (Axios v1.x+)
axios.get('/api/data', { signal: AbortSignal.timeout(5000) });
When to Reach for Axios
| Use Axios when | Use fetch when |
|---|---|
| You need request/response interceptors | You don’t want a 13KB dependency |
| You’re sharing code between browser + Node | You’re targeting browsers (or modern Node) only |
| Codebase already uses Axios consistently | Starting a new project in 2026 |
| You want auto-JSON-parsing + auto-error-throwing on 4xx/5xx | You want the modern Web standard |
For new projects in 2026, native fetch is the right default. Axios is the right pragmatic choice in many existing codebases.
Method 3 — TanStack Query (The React Standard)
For React apps, manual fetch/useEffect is the wrong default. TanStack Query (formerly React Query) handles caching, background refetching, request deduplication, retry logic, and automatic cancellation in a few lines:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async ({ signal }) => {
// signal is auto-passed — cancels on unmount or queryKey change
const response = await fetch(`/api/users/${userId}`, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
staleTime: 60_000, // consider data fresh for 60s
refetchOnWindowFocus: true, // refetch when user returns to tab
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={data} />;
}
What TanStack Query gives you for free:
- Cache keyed by
queryKey— same key = same cache entry across components - Request deduplication — 10 components asking for the same user fire ONE fetch
- Background refetching on window focus, network reconnect, or interval
- Automatic cancellation on unmount or
queryKeychange via passedsignal - Retry on transient failure (configurable; defaults to 3 retries with exponential backoff)
- Optimistic updates for mutations with rollback on failure
SWR (Vercel’s library) is the smaller alternative with similar features — useSWR('/api/users/42', fetcher) returns { data, error, isLoading } with automatic caching and revalidation.
For Vue, Pinia Colada and TanStack Query (Vue adapter) play the same role. For Svelte, TanStack Query (Svelte adapter). For Solid, TanStack Query (Solid adapter) or Solid Resources.
If you’re building a React app, the answer is not “raw fetch in useEffect” — it’s TanStack Query (or SWR for smaller apps).
Method 4 — ky / ofetch (Lightweight fetch Wrappers)
ky (by Sindre Sorhus) and ofetch (Nuxt’s HTTP layer) are modern lightweight wrappers around fetch. They give you Axios’s ergonomics — auto-JSON, auto-throw on 4xx/5xx, retry, hooks — with native fetch underneath and a tiny bundle (~5KB for ky, ~3KB for ofetch).
import ky from 'ky';
// One line — JSON auto-parsed, throws on 4xx/5xx, retries on network errors
const user = await ky.get('https://api.example.com/users/42').json();
// POST JSON — body wrapping is automatic
const newUser = await ky.post('https://api.example.com/users', {
json: { name: 'Ada' },
}).json();
// Custom instance with auth + retries + hooks
const api = ky.create({
prefixUrl: 'https://api.example.com',
retry: { limit: 3, backoffLimit: 3000 },
hooks: {
beforeRequest: [
req => req.headers.set('Authorization', `Bearer ${getToken()}`),
],
},
});
const data = await api.get('users/42').json();
import { ofetch } from 'ofetch';
// Same ergonomics — JSON auto-parsed, throws on 4xx/5xx
const user = await ofetch('/api/users/42');
const newUser = await ofetch('/api/users', {
method: 'POST',
body: { name: 'Ada' }, // ofetch handles JSON.stringify
retry: 3, // retry on network errors
timeout: 5000, // built-in timeout
});
ky vs Axios in 2026: ky is smaller (~5KB vs 13KB), uses native fetch under the hood (better tree-shaking, no XHR baggage), and ships modern features (retry, timeout, hooks) without the legacy. Axios still wins on ecosystem and existing-codebase muscle memory.
Method 5 — XMLHttpRequest (Legacy)
XMLHttpRequest is the original way to make AJAX requests, dating to 1999. It still works everywhere, but you should only use it for two things in 2026: upload progress events (which fetch added support for in Chrome 105+ but Safari still lacks as of 2026) and synchronous requests in service workers (rare).
// Modern XHR with upload progress — the one use case fetch still doesn't fully cover
function uploadWithProgress(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(e.loaded / e.total * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));
xhr.open('POST', url);
const formData = new FormData();
formData.append('file', file);
xhr.send(formData);
});
}
For everything else in 2026, use fetch. XHR is API debt.
Method 6 — jQuery AJAX (Legacy Footnote)
$.ajax() was the standard from 2006-2015. If you maintain a legacy site that already depends on jQuery, it still works:
$.ajax({
url: '/api/users/42',
method: 'GET',
success: (data) => console.log(data),
error: (jqXHR, status, error) => console.error(error),
});
For new code in 2026 there’s no reason to ship 86KB of jQuery for an HTTP request when fetch is native, smaller, and standard. Migrate when you can.
Comparison: Which Method for Which Job?
| Method | Bundle | Best for | Skip when |
|---|---|---|---|
| fetch + async/await | 0 KB (native) | Default for new code in 2026 | Need request interceptors (use Axios/ky) |
| fetch + AbortController | 0 KB | Any cancellable request | — |
| Axios | ~13 KB | Existing codebases, browser+Node code sharing, complex interceptor chains | Starting fresh in 2026 |
| TanStack Query | ~13 KB | Any React app with server state | Tiny apps with one fetch call |
| SWR | ~5 KB | Smaller React apps wanting caching | Need TanStack Query’s full feature set |
| ky | ~5 KB | Want Axios ergonomics, smaller bundle | Need Axios’s ecosystem |
| ofetch | ~3 KB | Nuxt apps, isomorphic JS | — |
| XHR | 0 KB (native) | Upload progress in Safari | Anywhere fetch works |
| jQuery AJAX | 86 KB | Already on jQuery for legacy reasons | New code |
Common Patterns Every API Call Tutorial Skips
Retry with Exponential Backoff (Cancellable)
async function fetchWithRetry(url, options = {}, retries = 3) {
const { signal } = options;
for (let attempt = 0; attempt <= retries; attempt++) {
signal?.throwIfAborted(); // bail out if cancelled
try {
const response = await fetch(url, options);
if (response.ok) return response.json();
if (response.status < 500) throw new Error(`HTTP ${response.status}`); // don't retry 4xx
throw new Error(`HTTP ${response.status}`);
} catch (err) {
if (err.name === 'AbortError' || attempt === retries) throw err;
const delay = Math.min(1000 * 2 ** attempt, 10_000);
await new Promise(r => setTimeout(r, delay));
}
}
}
Parallel Requests
Promise.all for fail-fast, Promise.allSettled for partial-success dashboards. See the Promise combinators guide for the complete decision rule:
const [user, posts, settings] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/settings').then(r => r.json()),
]);
POST with FormData (Files + Fields)
Covered above under fetch — never set Content-Type manually for FormData.
Authentication — Bearer Tokens
async function authFetch(url, options = {}) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
}
For refresh-token rotation, wrap this in retry logic that detects 401, refreshes the token, and re-fires the original request — or use Axios’s interceptors, which handle this elegantly.
Best Practices for API Calls in 2026
- Default to
fetch + async/await + AbortController— no library, modern standards, works everywhere - Always check
response.ok— fetch doesn’t throw on 4xx/5xx - Use
AbortSignal.timeout(ms)— never writesetTimeout(controller.abort, ms)manually - Cancel on unmount in React — controller in useEffect,
return () => controller.abort(). Better: use TanStack Query so this is automatic. - Never set
Content-Typefor FormData — the browser generates the multipart boundary - Validate response shape with Zod or similar — backends change, your runtime should catch it
- Use HTTPS —
fetchenforces mixed-content rules, but explicit HTTPS prevents accidents - Retry transient failures only (5xx, network errors) — never retry 4xx (your request is the problem)
- For React apps, use TanStack Query or SWR — manual
useEffect + fetchreinvents what they give you - For Axios codebases, migrate
CancelTokentoAbortController—CancelTokenis deprecated since v0.22 (2021)
Key Takeaways
- The 2026 baseline is
fetch + async/await + AbortController— built-in, no bundle cost, works in browsers, Node, Bun, Deno, Workers fetchdoesn’t reject on 4xx/5xx — always checkresponse.okbefore parsingAbortSignal.timeout(ms)is the modern timeout one-liner — distinctTimeoutErrorlets you tell timeouts from manual cancels- Axios CancelToken is deprecated since 0.22 — use
signal: AbortController.signallike with fetch - TanStack Query is the React standard — handles caching, deduplication, refetching, cancellation. Manual fetch in useEffect is the wrong default for new apps
- ky and ofetch are modern lightweight fetch wrappers — Axios ergonomics at 5KB / 3KB, native fetch underneath
- Never set
Content-Typefor FormData — browser generates the multipart boundary - Stream large responses with
response.body.getReader()— useful for AI responses, file downloads, log tailing - Type-safe API calls with Zod or Valibot —
schema.parse(data)throws on shape mismatch, catches backend changes early - Retry only transient errors (5xx, network) — never 4xx
- XHR is API debt unless you need upload progress in Safari; jQuery AJAX is legacy footnote — migrate when you can
- For runtime support:
fetchandAbortControllerare identical across browsers, Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge thanks to WinterCG alignment
FAQ
What is the difference between fetch and Axios?
fetch is built into every modern browser, Node 18+, Bun, Deno, and Cloudflare Workers — zero bundle cost. Axios is a 13KB library wrapping XHR (browser) and http (Node). Axios auto-parses JSON, auto-rejects on 4xx/5xx, and offers request/response interceptors. fetch requires you to call .json() and check response.ok manually. For new projects in 2026, native fetch is the right default; Axios is a pragmatic choice when an existing codebase already uses it consistently or you need its interceptor system.
Why doesn’t fetch reject on 404 or 500?
This is the #1 fetch gotcha. fetch only rejects the promise on network failures (DNS, CORS, offline) — a 404, 401, or 500 response resolves the promise with response.ok === false and response.status set to the error code. The reasoning: HTTP responses are still responses. To handle 4xx/5xx as errors, check response.ok before parsing and throw a manual error with the status code. Axios, ky, and ofetch auto-throw on 4xx/5xx, which many devs prefer.
How do I add a timeout to a fetch request?
Use AbortSignal.timeout(ms). fetch(url, { signal: AbortSignal.timeout(5000) }) auto-aborts after 5 seconds. The promise rejects with a TimeoutError (distinct from AbortError) so you can tell timeouts from manual cancels. Baseline in browsers since April 2024 and supported in Node 17+, Bun, Deno, Workers. For the complete cancellation story including signal composition with AbortSignal.any(), see the AbortController guide.
Should I use TanStack Query or just fetch in React?
For any React app with more than a few API calls, TanStack Query (or SWR for smaller apps). Manual useEffect + fetch reinvents what they give you for free: caching, request deduplication, background refetching, automatic cancellation on unmount, retries, optimistic updates. The 2026 React community consensus is that “fetch in useEffect” is an anti-pattern for production apps. TanStack Query auto-passes a signal to every queryFn so cancellation works without manual cleanup.
Is jQuery AJAX still relevant in 2026?
Only for maintaining legacy sites that already depend on jQuery. There’s no reason to ship 86KB of jQuery for an HTTP request when fetch is native, smaller, and standard. If you’re on jQuery for other reasons (existing plugins, $-selector patterns), $.ajax is fine for those existing calls. For new code, use fetch.
How do I cancel a fetch request?
Use AbortController: const controller = new AbortController(); fetch(url, { signal: controller.signal }); controller.abort();. The fetch promise rejects with an AbortError. Ignore the AbortError in your catch block — it means intentional cancellation. For the complete pattern including timeouts, signal composition, debounced searches, and React useEffect cleanup, see the AbortController guide.
How do I upload a file with fetch?
Use FormData: create a new FormData(), append('file', file), and pass it as body. Critical: never set Content-Type manually — the browser generates the multipart boundary string automatically. For upload progress, fetch supports it in Chrome 105+ and Firefox 117+ via ReadableStream request bodies, but Safari still requires XMLHttpRequest.upload.addEventListener('progress', ...) as of 2026. See the responsive images guide for the related upload pattern.
What’s the difference between ky and Axios?
ky is smaller (~5KB vs ~13KB), uses native fetch under the hood (better tree-shaking, no XHR baggage), and ships modern features like retry, timeout, hooks, and prefix URLs without the legacy. Axios still wins on ecosystem maturity and codebases that already use it. For new projects wanting “Axios ergonomics with a smaller bundle,” ky and ofetch are the modern picks.