AJAX
60 Questions & Answers — jQuery · Fetch · Axios · Basic to Advanced
AJAX (Asynchronous JavaScript and XML) sends HTTP requests from the browser in the background, receives data, and updates only the affected DOM nodes — no full page reload. The user stays on the same URL while the page content changes. The original mechanism was XMLHttpRequest; today Fetch and Axios wrap the same concept in cleaner APIs.
// Without AJAX — full page reload on every interaction
// <form action="/search" method="GET"> → browser navigates away
// With AJAX — partial update
document.querySelector('#searchInput').addEventListener('input', async (e) => {
const results = await fetch(`/api/search?q=${e.target.value}`).then(r => r.json());
document.querySelector('#results').innerHTML =
results.map(r => `<li>${r.title}</li>`).join('');
// Page URL unchanged, only #results updated
});XHR uses an event-callback model with verbose boilerplate: create an instance, open a connection, attach handlers, then send. Error detection requires checking both status and readyState. jQuery wrapped it in $.ajax(); Fetch replaced the model entirely with Promises, making async flow composable.
// Old XHR — verbose callback pyramid
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
console.log(data);
} else {
console.error('Error:', xhr.status);
}
}
};
xhr.send();
// Modern Fetch — Promise-based, composable
const data = await fetch('/api/users').then(r => r.json());
console.log(data);JavaScript runs on a single thread. When an AJAX call is made, the browser’s networking layer handles it off-thread. When the response arrives, a callback (or Promise resolution) is placed on the microtask queue. The event loop picks it up once the call stack is empty — so AJAX never blocks UI rendering.
console.log('1 — start'); // call stack
fetch('/api/data') // dispatched to browser networking
.then(r => r.json())
.then(data => console.log('3 — data received:', data)); // microtask queue
console.log('2 — sync continues'); // still on call stack
// Output order: 1 → 2 → 3
// "2" prints BEFORE the fetch resolves — JS didn't block waitingGET reads data (no body). POST creates a resource. PUT fully replaces a resource. PATCH partially updates it. DELETE removes it. REST APIs use these semantics consistently — your AJAX calls should match the server’s expected method or the request will be rejected.
const base = '/api/products';
// Read (GET)
const list = await fetch(base).then(r => r.json());
// Create (POST)
await fetch(base, { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: 'Widget', price: 9.99 }) });
// Replace (PUT)
await fetch(`${base}/42`, { method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: 'Widget Pro', price: 14.99 }) });
// Partial update (PATCH)
await fetch(`${base}/42`, { method: 'PATCH', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ price: 12.99 }) });
// Delete (DELETE)
await fetch(`${base}/42`, { method: 'DELETE' });HTTP bodies are text. JSON.stringify(obj) converts a JavaScript object to a JSON string for sending; JSON.parse(str) or response.json() converts the response text back to an object. Always set Content-Type: application/json so the server knows how to parse the body.
const payload = { name: 'Alice', age: 30, roles: ['admin', 'editor'] };
// JS object → JSON string → HTTP body
const body = JSON.stringify(payload);
// '{"name":"Alice","age":30,"roles":["admin","editor"]}'
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body // send JSON string
});
// HTTP response body → JSON string → JS object
const created = await res.json(); // shorthand for JSON.parse(await res.text())
console.log(created.id); // server-assigned id2xx = success. 3xx = redirect (browser follows automatically). 4xx = client error (400 bad request, 401 unauthorized, 403 forbidden, 404 not found, 422 validation). 5xx = server error. Fetch only rejects on network failure — you must check response.ok or response.status to detect 4xx/5xx.
async function apiFetch(url, options) {
const res = await fetch(url, options);
if (res.status === 401) { window.location.href = '/login'; return; }
if (res.status === 403) throw new Error('Forbidden');
if (res.status === 404) throw new Error('Resource not found');
if (res.status === 422) {
const errors = await res.json();
throw new ValidationError(errors);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 5xx etc.
return res.json();
}$.ajax() is the lowest-level jQuery AJAX method — all shorthand helpers call it internally. It accepts a config object covering URL, method, data, headers, timeout, content type, response type, and callbacks. Returns a jqXHR (jQuery’s Promise-compatible wrapper around XHR).
$.ajax({
url: '/api/orders',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ productId: 5, qty: 2 }),
headers: { 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') },
timeout: 10000, // 10 seconds
success: function (data) {
console.log('Created order:', data.id);
},
error: function (xhr, status, error) {
console.error(status, error, xhr.responseJSON);
},
complete: function () {
$('#spinner').hide();
}
});$.get(url, data, callback) fires a GET request; $.post(url, data, callback) fires a POST. Both return a jqXHR so you can chain .done(), .fail(), and .always(). They default to URL-encoded form data — for JSON bodies use $.ajax() with contentType: 'application/json'.
// Simple GET
$.get('/api/users', function (data) {
console.log(data);
});
// GET with query parameters
$.get('/api/search', { q: 'laptop', page: 1 }, function (results) {
renderResults(results);
});
// POST with form data
$.post('/api/login', { username: 'alice', password: 'secret' })
.done(data => { window.location.href = '/dashboard'; })
.fail(xhr => { showError(xhr.responseJSON.message); })
.always(() => { $('#spinner').hide(); });$.getJSON() is a shortcut for $.get(url, data, callback, 'json') — it automatically parses the response as JSON and passes the JavaScript object directly to the callback. No manual JSON.parse() needed. Also supports JSONP via a ?callback=? URL placeholder.
// Loads and parses JSON in one step
$.getJSON('/api/products', function (products) {
products.forEach(p => {
$('#list').append(`<li>${p.name} — $${p.price}</li>`);
});
});
// With query params
$.getJSON('/api/products', { category: 'electronics', limit: 20 })
.done(data => renderGrid(data))
.fail(() => showError('Failed to load products'));
// JSONP for cross-domain (legacy)
$.getJSON('https://api.example.com/data?callback=?', function (data) {
console.log(data);
});$.ajaxSetup() sets defaults that apply to every subsequent $.ajax() call. Common use: setting a base URL, default headers (CSRF token, Authorization), content type, and timeout once — so individual calls stay concise. Individual call options override the defaults.
// Run once at app startup
$.ajaxSetup({
contentType: 'application/json',
dataType: 'json',
timeout: 15000,
headers: {
'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content'),
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
error: function (xhr) {
if (xhr.status === 401) window.location.href = '/login';
}
});
// All subsequent calls inherit these defaults
$.get('/api/users'); // already sends auth header and parses JSONbeforeSend(xhr) runs just before the request is sent, giving access to the raw XHR object. You can add headers, check conditions, or return false to abort the request entirely. It is jQuery’s equivalent of an Axios request interceptor.
$.ajax({
url: '/api/secure-data',
method: 'GET',
beforeSend: function (xhr) {
const token = localStorage.getItem('authToken');
if (!token) {
alert('Not authenticated');
return false; // abort the request
}
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
$('#spinner').show();
},
success: function (data) { render(data); },
complete: function () { $('#spinner').hide(); }
});jQuery AJAX returns a jqXHR which implements the Deferred interface. $.when() accepts multiple Deferred objects and resolves when all complete — parallel execution with a single callback. Chaining .then() on jqXHR sequences dependent calls.
// Parallel — wait for both before rendering
$.when(
$.getJSON('/api/user/profile'),
$.getJSON('/api/user/orders')
).done(function (profileRes, ordersRes) {
const profile = profileRes[0];
const orders = ordersRes[0];
renderDashboard(profile, orders);
}).fail(function () {
showError('Could not load dashboard');
});
// Sequential — use the first result in the second call
$.getJSON('/api/cart')
.then(cart => $.post('/api/checkout', { cartId: cart.id }))
.then(order => { window.location.href = `/orders/${order.id}`; });.serialize() encodes all named form inputs as a URL-encoded string. .serializeArray() returns an array of {name, value} objects. For JSON APIs, convert the array to an object with Object.fromEntries(). This avoids listing every field manually.
$('#contactForm').on('submit', function (e) {
e.preventDefault();
// Option 1: URL-encoded string (default form content-type)
$.post('/api/contact', $(this).serialize())
.done(() => showSuccess());
// Option 2: JSON body
const formData = Object.fromEntries(
$(this).serializeArray().map(({ name, value }) => [name, value])
);
$.ajax({
url: '/api/contact',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(formData)
});
});$.ajaxPrefilter() registers a function that runs before every Ajax request — you can modify options, add a base URL prefix, or redirect certain request types. It is more powerful than ajaxSetup because it receives the current options, original options, and the jqXHR object.
const API_BASE = 'https://api.myapp.com/v2';
// Prepend base URL to all relative paths
$.ajaxPrefilter(function (options) {
if (options.url.startsWith('/')) {
options.url = API_BASE + options.url;
}
});
// Now all relative calls automatically hit the correct base:
$.get('/users'); // → https://api.myapp.com/v2/users
$.get('/products'); // → https://api.myapp.com/v2/productsFetch returns a Promise that resolves to a Response object — not the data directly. Reading the body is a second async step (response.json()). Unlike XHR, Fetch integrates with async/await, AbortController, and the Service Worker cache API. It is the modern standard for browser HTTP calls.
// Two-step resolution: network → body
const response = await fetch('/api/products'); // step 1: headers arrive
const products = await response.json(); // step 2: body streamed and parsed
// Why two steps? The Response arrives before the body is fully downloaded.
// You can inspect headers/status before deciding how to read the body:
const res = await fetch('/api/large-file');
if (res.headers.get('content-type').includes('application/json')) {
return res.json();
} else {
return res.blob();
}Fetch only rejects on network failure (no connection, DNS failure, CORS block). HTTP error status codes (4xx, 5xx) are treated as successful responses because the server did respond. Check response.ok (true for 200–299) or response.status explicitly and throw errors yourself.
// Common mistake — this .catch() won't catch a 404!
fetch('/api/missing')
.then(r => r.json())
.catch(err => console.error('Error:', err)); // never runs for 404
// Correct pattern — throw on non-OK status
async function fetchJSON(url, options) {
const res = await fetch(url, options);
if (!res.ok) {
const errorBody = await res.json().catch(() => ({}));
const err = new Error(errorBody.message || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}The body option accepts a string, FormData, URLSearchParams, Blob, or ReadableStream. For JSON, stringify the object and set Content-Type: application/json. FormData and URLSearchParams set their own content type automatically — don’t set it manually or you’ll break multipart boundaries.
// JSON body (most common for REST APIs)
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' })
});
// FormData — for file uploads or HTML form submissions
const form = new FormData(document.querySelector('#profileForm'));
form.append('avatar', fileInput.files[0]);
await fetch('/api/profile', { method: 'POST', body: form });
// DO NOT set Content-Type — browser sets it with boundary automatically
// URLSearchParams — application/x-www-form-urlencoded
await fetch('/api/subscribe', {
method: 'POST',
body: new URLSearchParams({ email: 'alice@example.com', plan: 'pro' })
});The Response body is a ReadableStream that can only be consumed once. Call the appropriate method to parse it: .json() for API data, .text() for HTML or plain text, .blob() for binary (images, PDFs), .arrayBuffer() for raw binary manipulation, .formData() for form submissions.
// JSON API response
const user = await fetch('/api/user/1').then(r => r.json());
// HTML snippet to inject into DOM
const html = await fetch('/partials/card.html').then(r => r.text());
document.querySelector('#container').innerHTML = html;
// Download and display an image
const blob = await fetch('/api/avatar/42').then(r => r.blob());
const imgUrl = URL.createObjectURL(blob);
document.querySelector('#avatar').src = imgUrl;
// Can only read body ONCE — clone if you need it twice
const res = await fetch('/api/data');
const copy = res.clone();
const json = await res.json();
const text = await copy.text(); // without clone, this would throwHeaders are passed as a Headers object or plain object in the headers option. Cookies are not sent by default in cross-origin requests — set credentials: 'include' for cross-origin or 'same-origin' (default) for same-origin only. 'omit' never sends cookies.
const headers = new Headers({
'Authorization': `Bearer ${getToken()}`,
'Content-Type': 'application/json',
'X-Request-ID': crypto.randomUUID(),
'Accept-Language': navigator.language
});
// Same-origin — cookies sent automatically
const res = await fetch('/api/profile', { headers });
// Cross-origin — must opt in to send cookies
const res2 = await fetch('https://api.example.com/data', {
headers,
credentials: 'include' // sends session cookies across domains
// Server must respond with: Access-Control-Allow-Credentials: true
// Access-Control-Allow-Origin: https://myapp.com (not *)
});Pass the controller’s signal to Fetch; calling controller.abort() immediately rejects the Promise with an AbortError. Essential for search-as-you-type (cancel previous request when user types again) and for component unmount cleanup in React.
// Search with request cancellation
let controller;
searchInput.addEventListener('input', async (e) => {
controller?.abort(); // cancel previous request
controller = new AbortController();
try {
const results = await fetch(
`/api/search?q=${e.target.value}`,
{ signal: controller.signal } // attach abort signal
).then(r => r.json());
renderResults(results);
} catch (err) {
if (err.name === 'AbortError') return; // ignore cancelled requests
showError(err.message);
}
});
// React cleanup
useEffect(() => {
const ctrl = new AbortController();
fetchData(ctrl.signal);
return () => ctrl.abort(); // cancel on unmount
}, []);Combine AbortController with setTimeout — if the request takes longer than the deadline, call abort(). Wrap in a helper so all calls get consistent timeout behavior. AbortSignal.timeout(ms) (Chrome 103+) is the native one-liner.
// Manual timeout helper
async function fetchWithTimeout(url, options = {}, ms = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timer);
return res;
} catch (err) {
if (err.name === 'AbortError') throw new Error(`Request timed out after ${ms}ms`);
throw err;
}
}
// Native one-liner (modern browsers)
const res = await fetch('/api/data', {
signal: AbortSignal.timeout(8000) // rejects after 8s
});Promise.all() starts all Promises simultaneously and resolves when every one resolves. If any rejects, the whole group rejects immediately. For independent requests this is far faster than sequential awaits (total time = slowest request, not sum of all). Use Promise.allSettled() when you want all results regardless of failures.
// Sequential — total time = 200ms + 150ms + 300ms = 650ms
const user = await fetch('/api/user').then(r => r.json());
const orders = await fetch('/api/orders').then(r => r.json());
const prefs = await fetch('/api/prefs').then(r => r.json());
// Parallel — total time = max(200, 150, 300) = 300ms
const [user2, orders2, prefs2] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/orders').then(r => r.json()),
fetch('/api/prefs').then(r => r.json())
]);
// allSettled — get all results even if some fail
const results = await Promise.allSettled([
fetch('/api/a').then(r => r.json()),
fetch('/api/b').then(r => r.json())
]);
results.forEach(r => r.status === 'fulfilled' ? use(r.value) : log(r.reason));Axios automatically serializes request objects to JSON and parses JSON responses — no JSON.stringify or res.json() needed. It rejects the Promise on 4xx/5xx status codes by default (Fetch does not). It also supports request/response interceptors, timeouts, upload progress, and automatic CSRF protection out of the box.
// Fetch: 3 manual steps
const res = await fetch('/api/user', { headers: { 'Content-Type': 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();
// Axios: 1 step — JSON parsed, errors thrown automatically
const { data: user2 } = await axios.get('/api/user');
// Axios response structure:
// { data, status, statusText, headers, config, request }Axios provides axios.get(url, config), axios.post(url, data, config), axios.put(url, data, config), axios.patch(url, data, config), and axios.delete(url, config). The second argument for mutation methods is the request body — not wrapped in a config object, unlike Fetch.
// GET with query params
const { data: products } = await axios.get('/api/products', {
params: { category: 'electronics', page: 1, limit: 20 }
// → /api/products?category=electronics&page=1&limit=20
});
// POST — body is 2nd argument, automatically JSON-serialized
const { data: created } = await axios.post('/api/products', {
name: 'Widget', price: 9.99, stock: 100
});
// PUT — full replacement
await axios.put(`/api/products/${id}`, { name: 'Widget Pro', price: 14.99, stock: 50 });
// PATCH — partial update
await axios.patch(`/api/products/${id}`, { price: 12.99 });
// DELETE
await axios.delete(`/api/products/${id}`);axios.create(config) returns a new instance with baked-in defaults. Different APIs (your own REST API vs a third-party service) get separate instances with their own base URLs, headers, and timeouts — no cross-contamination. Interceptors are scoped per instance.
// api/client.js
export const apiClient = axios.create({
baseURL: 'https://api.myapp.com/v2',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
// Attach auth token via interceptor
apiClient.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${store.getState().auth.token}`;
return config;
});
// Usage — baseURL is prepended automatically
const { data } = await apiClient.get('/users'); // → https://api.myapp.com/v2/usersRequest interceptors modify the config before the request is sent — add auth headers, start a loading indicator. Response interceptors transform the data or handle errors after every response — refresh expired tokens, log errors, unwrap a nested data.data envelope. Both interceptors can be async.
// Request interceptor — add auth
apiClient.interceptors.request.use(
config => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
},
error => Promise.reject(error)
);
// Response interceptor — refresh token on 401
apiClient.interceptors.response.use(
response => response, // pass-through on success
async error => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
await refreshAuthToken();
error.config.headers.Authorization = `Bearer ${getToken()}`;
return apiClient(error.config); // retry original request
}
return Promise.reject(error);
}
);Axios supports native AbortController via the signal config option (Axios 0.22+). A timeout option in milliseconds is also built in — no manual setTimeout needed. Cancellation throws an axios.isCancel(error)-detectable error; timeouts throw an ECONNABORTED error.
// Timeout — built in
const { data } = await axios.get('/api/data', { timeout: 5000 }); // 5s
// Cancellation via AbortController
const controller = new AbortController();
const request = axios.get('/api/search', {
params: { q: query },
signal: controller.signal
});
// Cancel it (e.g. when user types again)
controller.abort();
try {
await request;
} catch (err) {
if (axios.isCancel(err)) console.log('Request cancelled');
else throw err;
}Axios exposes onUploadProgress and onDownloadProgress callbacks that receive a ProgressEvent. Divide loaded by total for a percentage. This is impossible with plain Fetch (no built-in progress — requires reading the response as a stream manually).
const formData = new FormData();
formData.append('file', fileInput.files[0]);
await axios.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (evt) => {
if (evt.total) {
const pct = Math.round((evt.loaded / evt.total) * 100);
progressBar.style.width = `${pct}%`;
progressLabel.textContent = `${pct}%`;
}
},
onDownloadProgress: (evt) => {
if (evt.total) {
const pct = Math.round((evt.loaded / evt.total) * 100);
console.log(`Download: ${pct}%`);
}
}
});axios.all() was a deprecated alias for Promise.all() — use Promise.all() directly with Axios calls. Each call returns a Promise; destructure the results array. Interceptors still apply to each individual request.
// Parallel Axios requests
const [userRes, ordersRes, settingsRes] = await Promise.all([
apiClient.get('/user/profile'),
apiClient.get('/user/orders'),
apiClient.get('/user/settings')
]);
const user = userRes.data;
const orders = ordersRes.data;
const settings = settingsRes.data;
renderDashboard(user, orders, settings);
// allSettled version — partial results on failure
const results = await Promise.allSettled([
apiClient.get('/api/a'),
apiClient.get('/api/b')
]);
const data = results.filter(r => r.status === 'fulfilled').map(r => r.value.data);On 4xx/5xx, Axios throws an error object with error.response (server responded), error.request (request sent, no response), or neither (request config error). Check error.response.status for the code and error.response.data for the response body — no manual parsing needed.
try {
const { data } = await apiClient.post('/api/register', formData);
showSuccess(data.message);
} catch (error) {
if (error.response) {
// Server responded with error status
const { status, data } = error.response;
if (status === 422) showValidationErrors(data.errors);
else if (status === 409) showError('Email already taken');
else showError(`Server error: ${status}`);
} else if (error.request) {
// Request sent but no response (network issue, timeout)
showError('Network error — please check your connection');
} else {
// Request config error
showError(`Request error: ${error.message}`);
}
}await pauses an async function until a Promise resolves — the function suspends (without blocking the thread) and resumes with the resolved value. This eliminates Promise chain indentation and makes sequential async logic as readable as synchronous code.
// Promise chains — deep nesting
function loadDashboard() {
return fetch('/api/user')
.then(r => r.json())
.then(user => fetch(`/api/orders?userId=${user.id}`))
.then(r => r.json())
.then(orders => render(orders));
}
// async/await — reads top-to-bottom
async function loadDashboard() {
const user = await fetch('/api/user').then(r => r.json());
const orders = await fetch(`/api/orders?userId=${user.id}`).then(r => r.json());
render(orders);
}Wrap the entire async block in try/catch — network failures (connection refused, DNS) and manually thrown HTTP errors both end up in the catch block. Use finally for cleanup (hide spinner, re-enable button) that must run regardless of success or failure.
async function submitOrder(payload) {
setLoading(true);
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || `HTTP ${res.status}`);
}
const order = await res.json();
showSuccess(`Order #${order.id} created!`);
return order;
} catch (err) {
showError(err.message); // catches both network errors and thrown HTTP errors
return null;
} finally {
setLoading(false); // always runs
}
}Promise.all() fast-fails — one rejected Promise immediately rejects the whole group. Promise.allSettled() waits for every Promise regardless, then returns an array of {status:'fulfilled', value} or {status:'rejected', reason}. Use it when partial results are acceptable.
const endpoints = ['/api/news', '/api/weather', '/api/stocks'];
// allSettled — load what you can, show what's available
const results = await Promise.allSettled(
endpoints.map(url => fetch(url).then(r => r.json()))
);
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
renderWidget(endpoints[i], result.value);
} else {
renderErrorWidget(endpoints[i], result.reason.message);
}
});
// All three widgets render — even if /api/stocks is downPromise.race() resolves or rejects with the first Promise that settles. Racing a fetch against a timeout Promise means whichever happens first wins. Note: the underlying request is not cancelled — use AbortController to actually stop the network call.
function timeout(ms) {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
);
}
// Race: whichever finishes first wins
try {
const data = await Promise.race([
fetch('/api/slow-endpoint').then(r => r.json()),
timeout(5000)
]);
render(data);
} catch (err) {
showError(err.message); // "Timed out after 5000ms" or network error
}
// Better: AbortController actually cancels the request
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
await fetch('/api/slow-endpoint', { signal: ctrl.signal });Sequential awaits run one after another — total time is the sum of all request durations. Parallel requests run simultaneously — total time equals the slowest. The common mistake is sequential awaiting inside a forEach or map — use Promise.all(arr.map(...)) for true parallelism.
const ids = [1, 2, 3, 4, 5];
// ❌ Sequential — 5 requests in series, ~500ms each = ~2500ms total
for (const id of ids) {
const item = await fetch(`/api/items/${id}`).then(r => r.json());
render(item);
}
// ✅ Parallel — all 5 fire at once = ~500ms total
const items = await Promise.all(
ids.map(id => fetch(`/api/items/${id}`).then(r => r.json()))
);
items.forEach(render);
// ✅ Parallel with concurrency limit (batched) — e.g. 2 at a time
async function batchFetch(ids, batchSize = 2) {
const results = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = await Promise.all(
ids.slice(i, i + batchSize).map(id => fetch(`/api/items/${id}`).then(r => r.json()))
);
results.push(...batch);
}
return results;
}A retry wrapper catches transient failures (network errors, 5xx) and re-attempts up to N times with optional exponential backoff. Don’t retry on 4xx client errors — they won’t fix themselves. Axios can also be enhanced with the axios-retry library.
async function fetchWithRetry(url, options = {}, retries = 3, backoff = 300) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, options);
if (res.status >= 500 && attempt < retries) throw new Error('Server error');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch (err) {
if (attempt === retries) throw err;
const delay = backoff * Math.pow(2, attempt); // 300 → 600 → 1200ms
await new Promise(r => setTimeout(r, delay));
console.log(`Retry ${attempt + 1}/${retries} after ${delay}ms`);
}
}
}
// Axios with axios-retry library
import axiosRetry from 'axios-retry';
axiosRetry(apiClient, { retries: 3, retryDelay: axiosRetry.exponentialDelay });The browser blocks AJAX responses from a different origin (scheme + host + port) unless the server includes Access-Control-Allow-Origin in the response. The restriction is enforced by the browser — the request still reaches the server. Only the response is blocked from JavaScript.
// Browser blocks this — different origin
const res = await fetch('https://api.external.com/data');
// Error: CORS policy: No 'Access-Control-Allow-Origin' header
// Server must respond with:
// Access-Control-Allow-Origin: https://myapp.com (specific) OR * (public API)
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE
// Access-Control-Allow-Headers: Content-Type, Authorization
// Workaround in development — proxy through your own server
// vite.config.js:
// server: { proxy: { '/api': { target: 'https://api.external.com', changeOrigin: true } } }A preflight OPTIONS request is sent automatically by the browser before any “non-simple” cross-origin request. Triggers: Content-Type: application/json, custom headers, or methods other than GET/POST. The server must respond to OPTIONS with the allowed headers/methods before the browser sends the real request.
// This POST triggers a preflight because Content-Type is application/json
await fetch('https://api.external.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // triggers preflight!
body: JSON.stringify({ name: 'Alice' })
});
// Browser first sends:
// OPTIONS /users
// Origin: https://myapp.com
// Access-Control-Request-Method: POST
// Access-Control-Request-Headers: content-type
// Server must respond:
// Access-Control-Allow-Origin: https://myapp.com
// Access-Control-Allow-Methods: POST
// Access-Control-Allow-Headers: Content-Type
// Access-Control-Max-Age: 86400 ← cache preflight for 1 dayAfter login, the server returns a signed JWT. Store it in memory (most secure) or localStorage (convenient but XSS-vulnerable). Attach it as an Authorization: Bearer <token> header on every AJAX request. The server validates the signature and extracts claims without a database lookup.
// Login — store token
const { data } = await axios.post('/api/auth/login', { email, password });
localStorage.setItem('token', data.accessToken);
// Axios interceptor — attach to all requests
apiClient.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Fetch helper
async function authFetch(url, options = {}) {
return fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
...options.headers
}
});
}CSRF attacks forge state-changing requests from a malicious site using the victim’s cookies. CSRF tokens counter this — a secret per-session token is embedded in the page; AJAX must send it in a header. The attacker’s site cannot read the token (same-origin policy), so the forged request fails.
// Token embedded in meta tag by the server
// <meta name="csrf-token" content="abc123xyz">
function getCsrf() {
return document.querySelector('meta[name="csrf-token"]').content;
}
// Fetch — add token header
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify(order)
});
// Axios — global setup
axios.defaults.headers.common['X-CSRF-Token'] = getCsrf();
// jQuery — global setup
$.ajaxSetup({ headers: { 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') } });Never assign API strings directly to innerHTML — a malicious server or compromised CDN can return <script> payloads. Use textContent for plain text, DOM APIs for structure, or a sanitizer like DOMPurify for trusted HTML. Template literals with innerHTML are dangerous if any variable comes from outside.
const { data: user } = await axios.get('/api/user');
// ❌ XSS risk — user.name could be "<script>stealCookies()</script>"
container.innerHTML = `<h1>Hello, ${user.name}!</h1>`;
// ✅ Safe — textContent escapes all HTML
const h1 = document.createElement('h1');
h1.textContent = `Hello, ${user.name}!`;
container.appendChild(h1);
// ✅ Safe — DOM API
const img = document.createElement('img');
img.src = user.avatarUrl; // setAttribute sanitizes
img.alt = user.name;
container.appendChild(img);
// ✅ For trusted rich HTML — sanitize first
import DOMPurify from 'dompurify';
container.innerHTML = DOMPurify.sanitize(user.bio);Short-lived access tokens expire frequently; refresh tokens (longer-lived, HTTP-only cookie) are used to silently obtain a new access token. An Axios response interceptor catches 401s, calls the refresh endpoint, updates the stored token, and retries the original failed request — completely transparent to the user.
let isRefreshing = false;
let failedQueue = [];
apiClient.interceptors.response.use(null, async error => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) return Promise.reject(error);
original._retry = true;
if (isRefreshing) {
return new Promise((resolve, reject) =>
failedQueue.push({ resolve, reject })
).then(() => apiClient(original));
}
isRefreshing = true;
try {
const { data } = await axios.post('/api/auth/refresh'); // uses HTTP-only cookie
setToken(data.accessToken);
failedQueue.forEach(p => p.resolve());
failedQueue = [];
return apiClient(original); // retry
} catch {
failedQueue.forEach(p => p.reject());
logout();
} finally {
isRefreshing = false;
}
});Debouncing delays execution until the user stops typing for a defined period. Without it, every keystroke fires a request — 10 characters = 10 requests, most arriving out of order. Combine debounce with AbortController to cancel the previous request if the debounce fires again.
function debounce(fn, ms) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}
let searchCtrl;
const search = debounce(async (query) => {
searchCtrl?.abort();
searchCtrl = new AbortController();
if (!query.trim()) { clearResults(); return; }
try {
const { data } = await axios.get('/api/search', {
params: { q: query },
signal: searchCtrl.signal
});
renderResults(data);
} catch (err) {
if (!axios.isCancel(err)) showError(err.message);
}
}, 300); // wait 300ms after last keystroke
searchInput.addEventListener('input', e => search(e.target.value));Optimistic UI applies the expected outcome immediately to the DOM and fires the AJAX call in the background. If the server responds successfully, nothing changes. On failure, roll back the DOM to its previous state and show an error. This makes the UI feel instant even over slow connections.
async function toggleLike(postId, btn) {
const wasLiked = btn.classList.contains('liked');
const prevCount = parseInt(btn.dataset.count);
// 1. Apply optimistic update immediately
btn.classList.toggle('liked');
btn.dataset.count = wasLiked ? prevCount - 1 : prevCount + 1;
btn.querySelector('.count').textContent = btn.dataset.count;
try {
// 2. Fire actual request
await axios.post(`/api/posts/${postId}/like`);
// Success: UI already correct, nothing to do
} catch (err) {
// 3. Rollback on failure
btn.classList.toggle('liked');
btn.dataset.count = prevCount;
btn.querySelector('.count').textContent = prevCount;
showToast('Could not save — please try again');
}
}Cursor-based pagination sends a cursor (the ID or timestamp of the last item) instead of a page number — immune to items shifting between pages. An IntersectionObserver triggers the next fetch when a sentinel element enters the viewport, replacing manual scroll event listeners.
let cursor = null;
let loading = false;
const sentinel = document.querySelector('#load-more-sentinel');
const observer = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || loading) return;
loading = true;
try {
const { data } = await axios.get('/api/feed', {
params: { cursor, limit: 20 }
});
data.items.forEach(item => appendCard(item));
cursor = data.nextCursor;
if (!data.nextCursor) observer.disconnect(); // no more pages
} finally {
loading = false;
}
}, { rootMargin: '200px' }); // trigger 200px before sentinel is visible
observer.observe(sentinel);Axios has native onUploadProgress. Fetch has no upload progress — use XHR directly or wrap it. For download progress with Fetch, read the response.body as a stream and calculate bytes received vs Content-Length.
// Axios — clean progress API
async function uploadFile(file) {
const form = new FormData();
form.append('file', file);
await axios.post('/api/upload', form, {
onUploadProgress: ({ loaded, total }) => {
const pct = Math.round(loaded / total * 100);
document.querySelector('#bar').style.width = `${pct}%`;
document.querySelector('#label').textContent = `${pct}%`;
}
});
}
// XHR — for Fetch upload progress (Fetch lacks this)
function uploadXHR(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.onprogress = (e) => {
document.querySelector('#bar').style.width = `${Math.round(e.loaded / e.total * 100)}%`;
};
xhr.onload = () => resolve(JSON.parse(xhr.responseText));
xhr.onerror = () => reject(new Error('Upload failed'));
const form = new FormData(); form.append('file', file);
xhr.send(form);
});
}Polling repeatedly calls an endpoint at a fixed interval using setInterval or recursive setTimeout. Simple but wasteful — most calls return “no change.” Long-polling holds the connection open until data is available, reducing empty responses. Neither beats WebSockets or SSE for true real-time needs.
// Short polling — every 3 seconds
function startPolling(orderId) {
const timer = setInterval(async () => {
const { data } = await axios.get(`/api/orders/${orderId}/status`);
updateStatusBadge(data.status);
if (['delivered', 'cancelled'].includes(data.status)) {
clearInterval(timer); // stop when terminal state reached
}
}, 3000);
return timer;
}
// Long polling — server holds response until update
async function longPoll(lastId) {
try {
const { data } = await axios.get('/api/notifications', {
params: { after: lastId, timeout: 30 } // server waits up to 30s
});
data.items.forEach(showNotification);
longPoll(data.lastId); // immediately re-poll
} catch {
setTimeout(() => longPoll(lastId), 5000); // backoff on error
}
}In-memory caches (a Map) store responses keyed by URL for the session lifetime. localStorage persists across sessions with a TTL check. Deduplication maps (pending requests) prevent the same call from firing twice simultaneously — a common bug in component-heavy UIs.
const cache = new Map(); // { url → { data, expiresAt } }
const pending = new Map(); // { url → Promise } — deduplication
async function cachedFetch(url, ttlMs = 60_000) {
// Return from cache if fresh
const hit = cache.get(url);
if (hit && Date.now() < hit.expiresAt) return hit.data;
// Deduplicate simultaneous calls for the same URL
if (pending.has(url)) return pending.get(url);
const promise = axios.get(url)
.then(({ data }) => {
cache.set(url, { data, expiresAt: Date.now() + ttlMs });
pending.delete(url);
return data;
})
.catch(err => { pending.delete(url); throw err; });
pending.set(url, promise);
return promise;
}
// Usage — only one network request even if called 5× simultaneously
const [a, b, c] = await Promise.all([
cachedFetch('/api/config'),
cachedFetch('/api/config'), // same URL — deduped
cachedFetch('/api/config') // same URL — deduped
]);A Repository class wraps all AJAX calls for one domain entity. Components call orderRepo.create(), not axios.post('/api/orders'). When the API changes (base URL, auth, data shape), only the repository changes — zero component updates. Also trivial to mock in tests.
// repositories/OrderRepository.js
export class OrderRepository {
constructor(client = apiClient) { this.client = client; }
async findAll(filters = {}) { return (await this.client.get('/orders', { params: filters })).data; }
async findById(id) { return (await this.client.get(`/orders/${id}`)).data; }
async create(payload) { return (await this.client.post('/orders', payload)).data; }
async update(id, payload) { return (await this.client.patch(`/orders/${id}`, payload)).data; }
async remove(id) { return (await this.client.delete(`/orders/${id}`)).data; }
}
// Component — no AJAX knowledge
const repo = new OrderRepository();
const orders = await repo.findAll({ status: 'pending' });
// Test — swap with mock
const mockRepo = { findAll: vi.fn().mockResolvedValue([]) };
render(<OrderList repo={mockRepo} />);For large responses (CSV exports, bulk data), reading the body as a stream processes chunks as they arrive rather than buffering everything in memory. Combine with a TextDecoder to reassemble text chunks. The server must support streaming (chunked transfer encoding).
async function streamDownload(url, onProgress) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const total = parseInt(res.headers.get('Content-Length') || '0');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let received = 0;
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
text += decoder.decode(value, { stream: true });
onProgress(total ? received / total : 0);
}
return JSON.parse(text);
}
// Usage with progress bar
await streamDownload('/api/export/large', (pct) => {
progressBar.style.width = `${Math.round(pct * 100)}%`;
});SSE opens a single long-lived HTTP connection; the server pushes data whenever it has something to say. The browser’s EventSource API handles reconnection automatically. Unlike polling it sends zero requests when idle; unlike WebSockets it is unidirectional (server → client) and works over plain HTTP/2.
// Connect — browser sends one GET, server keeps connection open
const evtSource = new EventSource('/api/notifications/stream');
// Listen for events
evtSource.addEventListener('notification', (e) => {
const notif = JSON.parse(e.data);
showToast(notif.message);
updateBadge(notif.unreadCount);
});
evtSource.addEventListener('heartbeat', () => {
console.log('Server alive');
});
evtSource.onerror = () => {
console.warn('SSE disconnected — browser will reconnect automatically');
};
// Close when leaving page
window.addEventListener('beforeunload', () => evtSource.close());A request queue buffers calls and releases them at a controlled rate (e.g., 10 per second). When a 429 Too Many Requests response arrives, pause the queue for the duration specified in the Retry-After header, then resume. This is essential for bulk operations against third-party APIs.
class RateLimitedQueue {
constructor(requestsPerSecond = 10) {
this.rps = requestsPerSecond;
this.queue = [];
this.running = 0;
}
enqueue(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.drain();
});
}
async drain() {
if (this.running >= this.rps || !this.queue.length) return;
const { fn, resolve, reject } = this.queue.shift();
this.running++;
try {
resolve(await fn());
} catch (err) {
if (err.response?.status === 429) {
const wait = (parseInt(err.response.headers['retry-after']) || 1) * 1000;
this.queue.unshift({ fn, resolve, reject });
await new Promise(r => setTimeout(r, wait));
} else { reject(err); }
} finally {
this.running--;
this.drain();
}
}
}MSW intercepts requests at the network level (not by monkey-patching fetch/axios) — your production code runs unmodified. Define handlers that return mock responses; the same handlers work in Jest (via Node) and in the browser (via Service Worker). Tests become integration tests, not mocked-function tests.
// handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'Alice' }]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 2, ...body }, { status: 201 });
}),
http.get('/api/users/:id', ({ params }) => {
if (params.id === '999') return new HttpResponse(null, { status: 404 });
return HttpResponse.json({ id: params.id, name: 'Alice' });
})
];
// setupTests.js
import { setupServer } from 'msw/node';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());GraphQL uses a single endpoint (usually /graphql) and accepts a query in the POST body describing exactly what fields the client needs. Unlike REST (multiple endpoints, fixed response shapes), GraphQL eliminates over-fetching (receiving unused fields) and under-fetching (requiring multiple round-trips to assemble a view).
// GraphQL over Fetch — single endpoint
async function gql(query, variables = {}) {
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` },
body: JSON.stringify({ query, variables })
});
const { data, errors } = await res.json();
if (errors) throw new Error(errors[0].message);
return data;
}
// Fetch user + their last 3 orders in ONE request
const { user } = await gql(`
query GetUserWithOrders($id: ID!) {
user(id: $id) {
name email
orders(last: 3) { id total status createdAt }
}
}
`, { id: '42' });Service Workers run in a background thread and intercept every fetch event from the page. A “stale-while-revalidate” strategy returns the cached response immediately (fast) while fetching a fresh copy in the background to update the cache for next time — combining speed with freshness.
// service-worker.js
const CACHE = 'api-cache-v1';
const API_PATTERN = /^\/api\//;
self.addEventListener('fetch', (event) => {
if (!API_PATTERN.test(new URL(event.request.url).pathname)) return;
// Stale-while-revalidate
event.respondWith(
caches.open(CACHE).then(async cache => {
const cached = await cache.match(event.request);
const networkFetch = fetch(event.request.clone())
.then(res => { cache.put(event.request, res.clone()); return res; })
.catch(() => null);
return cached || networkFetch; // return cache instantly, update silently
})
);
});React Query replaces manual state management for AJAX — no more useState for loading/error/data, no manual cache, no duplicate requests. It deduplicates in-flight requests, caches results with configurable staleness, auto-refetches on window focus or network reconnect, and provides pagination and mutation helpers.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch — fully managed: loading, error, caching, refetch
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => axios.get('/api/products').then(r => r.data),
staleTime: 5 * 60 * 1000 // consider fresh for 5 minutes
});
if (isLoading) return <Spinner />;
if (error) return <Error msg={error.message} />;
return data.map(p => <ProductCard key={p.id} product={p} />);
}
// Mutation — invalidates cache on success (auto-refetch)
const qc = useQueryClient();
const { mutate } = useMutation({
mutationFn: (p) => axios.post('/api/products', p),
onSuccess: () => qc.invalidateQueries({ queryKey: ['products'] })
});In component-heavy UIs, multiple components may request the same data simultaneously on mount. Without deduplication, 5 components = 5 identical network requests. A pending-request map stores in-flight Promises; subsequent callers for the same key get the same Promise — one request, many consumers.
const inflight = new Map();
export async function deduplicatedFetch(url) {
if (inflight.has(url)) {
console.log(`Deduped: ${url}`);
return inflight.get(url); // return the SAME promise
}
const promise = fetch(url)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.finally(() => inflight.delete(url));
inflight.set(url, promise);
return promise;
}
// 5 simultaneous calls → 1 network request
const results = await Promise.all([
deduplicatedFetch('/api/config'),
deduplicatedFetch('/api/config'),
deduplicatedFetch('/api/config'),
deduplicatedFetch('/api/config'),
deduplicatedFetch('/api/config')
]);Centralizing base URL configuration in an Axios instance means switching between dev/staging/prod requires changing one constant. API version upgrades (v1 → v2) happen in one place. An interceptor can add version headers or transform request paths — all components remain unaware of the environment or version.
// config/api.js
const BASE_URLS = {
development: 'http://localhost:3000/api/v2',
staging: 'https://staging.myapp.com/api/v2',
production: 'https://api.myapp.com/v2'
};
export const apiClient = axios.create({
baseURL: BASE_URLS[import.meta.env.MODE] ?? BASE_URLS.production,
timeout: 15_000,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Client-Version': import.meta.env.VITE_APP_VERSION
}
});
// Usage — environment is irrelevant to the caller
const products = await apiClient.get('/products'); // baseURL prepended automaticallyAJAX is request-response: the client always initiates. WebSockets are bidirectional: either side can send at any time over a persistent TCP connection. Use AJAX for traditional data loading; use WebSockets for chat, collaborative editing, live game state, or any scenario requiring sub-second server-push.
// WebSocket — persistent bidirectional connection
const ws = new WebSocket('wss://api.myapp.com/chat');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
});
ws.addEventListener('message', ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === 'chat') appendMessage(msg);
if (msg.type === 'typing') showTypingIndicator(msg.user);
});
// Send a message — no HTTP request, instant delivery
document.querySelector('#send').onclick = () => {
ws.send(JSON.stringify({ type: 'chat', text: input.value }));
input.value = '';
};
ws.addEventListener('close', () => reconnect());A counter incremented on each request start and decremented on each finish drives a single global spinner. Axios interceptors manage the counter centrally — no component needs to call setLoading(true/false). The spinner appears as soon as any request starts and disappears when all are done.
let activeRequests = 0;
const spinner = document.querySelector('#global-spinner');
function setSpinner(active) {
activeRequests += active ? 1 : -1;
spinner.style.display = activeRequests > 0 ? 'flex' : 'none';
}
// Axios interceptors — automatically track all requests
apiClient.interceptors.request.use(config => {
setSpinner(true);
return config;
});
apiClient.interceptors.response.use(
response => { setSpinner(false); return response; },
error => { setSpinner(false); return Promise.reject(error); }
);
// Now every axios call through apiClient shows/hides the spinner automatically
// No component needs: setLoading(true) / setLoading(false)