What is AJAX?
AJAX stands for Asynchronous JavaScript and XML, a name coined in 2005 when Gmail and Google Maps first showed that a page could fetch new data without a full reload. The XML part is mostly history — modern APIs return JSON — but the core idea remains: talk to a server in the background and update the page in place.
The original tool was XMLHttpRequest. It works, but its API is awkward and callback-based. The modern replacement is the Fetch API, a promise-based interface available in browsers, Node.js, Deno and edge runtimes.
Your first fetch request
fetch takes a URL and returns a promise for a Response.
// basic.js
const response = await fetch("https://api.example.com/users");
const users = await response.json();
console.log(users);
That is the whole happy path. The subtlety is that fetch resolves even for error status codes, so a 404 does not throw. You have to inspect the response yourself.
The Response object
The Response object tells you what the server sent back.
// response.js
const response = await fetch("/api/users");
response.ok; // true for status 200–299
response.status; // 200, 404, 500, ...
response.statusText;
response.headers.get("content-type");
const data = await response.json(); // parse JSON
const text = await response.text(); // raw text
const blob = await response.blob(); // binary data
const form = await response.formData();
A body can only be read once. If you need both the raw text and the parsed JSON, call response.clone() before reading, or parse the text yourself. Also note that response.json() rejects if the body is not valid JSON — another reason to wrap calls in try/catch.
Handling errors properly
There are two kinds of failure, and they behave differently.
- Network errors — no connection, DNS failure, blocked CORS.
fetchrejects, socatchhandles them. - HTTP errors — 404, 401, 500.
fetchresolves; you must checkresponse.okorresponse.status.
// errors.js
async function getUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Could not load user:", error);
throw error;
}
}
Treating both cases explicitly is what separates fragile code from resilient code. The Error Handling guide covers the broader strategy.
Sending data with POST, PUT and DELETE
Pass an options object to change the method, add headers and attach a body.
// create.js
async function createUser(user) {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(user),
});
if (!response.ok) throw new Error("Could not create user");
return response.json();
}
await createUser({ name: "Ada", role: "engineer" });
Use PUT to replace a resource, PATCH to update part of it, and DELETE to remove it. For file uploads, build a FormData object and pass it as the body — the browser sets the correct multipart content type for you.
Headers and authentication
Headers carry metadata about the request. You can set them per request, and many APIs expect an authorization token.
// auth.js
const response = await fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
Never hard-code secrets in client-side code — anything shipped to the browser is public. Tokens should come from a login flow and be stored with care.
Cancelling requests
A request that is no longer relevant — the user typed another character, navigated away or the component unmounted — should be cancelled. AbortController does exactly that.
// abort.js
const controller = new AbortController();
fetch("/api/search?q=javascript", { signal: controller.signal })
.then((res) => res.json())
.then(console.log)
.catch((error) => {
if (error.name === "AbortError") return;
console.error(error);
});
// Cancel when it is no longer needed
controller.abort();
Because fetch has no timeout option, AbortController is also the standard way to implement one.
// timeout.js
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch("/api/slow", { signal: controller.signal });
return await res.json();
} finally {
clearTimeout(timer);
}
CORS and credentials
Browsers enforce the same-origin policy: JavaScript cannot read responses from a different origin unless the server opts in. That opt-in is CORS, and it is configured entirely on the server through response headers such as Access-Control-Allow-Origin. If you see a CORS error, the fix belongs in the server configuration, not in your fetch call.
By default, fetch does not send cookies to cross-origin URLs. To include them, set credentials: "include" and make sure the server allows credentialed requests. This is a common source of “it works in Postman but not the browser” confusion.
Practical patterns
Loading, success and error states. Always reflect the request lifecycle in the UI.
// state.js
async function loadPosts() {
showSpinner();
try {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
renderPosts(await res.json());
} catch (error) {
showError("Could not load posts. Try again.");
} finally {
hideSpinner();
}
}
Debounced search. Cancel the previous request whenever a new one starts, so slow responses cannot overwrite fresh results. Pair AbortController with a small debounce delay.
Parallel requests. Use Promise.all when several endpoints are needed at once, and Promise.allSettled when a partial result is still useful.
Best practices
- Always check
response.okbefore parsing the body. - Set an explicit
Acceptheader and aContent-Typewhen sending a body. - Wrap awaited requests in
try/catchand surface a useful message. - Cancel stale requests with
AbortController. - Keep API calls in a small module instead of scattering URLs through components.
- Never put secrets in client-side code.
- Show loading and error states so the UI never feels broken.
Common mistakes
- Assuming
fetchthrows on a 404 or 500. - Forgetting
JSON.stringifyon a request body. - Missing the
Content-Typeheader and receiving a parse error from the server. - Reading a response body twice.
- Ignoring CORS until the browser blocks the request in production.
- Leaving requests uncancelled and letting stale data win a race.
Where to go next
Fetch is the bridge between your front end and the world. Combine it with the DOM to render results, async/await to sequence work, and error handling to fail gracefully. From there, deepen your understanding of methods, status codes and headers, since they shape every request you send.