A robust AJAX flow separates transport failure, HTTP failure, response-decoding failure, validation failure, cancellation, and stale results. The Fetch API rejects its promise for request failures such as an unavailable network, but a 404 or 500 still resolves to a Response. Application code must inspect response.ok or response.status before treating the body as success.
After this lesson, you can preserve useful server error details, give the user a recoverable state, cancel obsolete work, and avoid showing an older response after a newer request has completed.
There are two distinct categories of errors in AJAX:
async function fetchData(url) {
try {
const response = await fetch(url);
// HTTP errors: fetch resolves but response.ok is false
if (!response.ok) {
// Try to read error details from the response body
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorBody = await response.json();
errorMessage = errorBody.message || errorMessage;
} catch {
// response body wasn't JSON - use the status text
}
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
if (error.name === 'TypeError') {
// Network error - fetch rejected (no internet, CORS, etc.)
console.error('Network error:', error.message);
showUserError('No internet connection. Please try again.');
} else if (error.name === 'AbortError') {
console.warn('Request was cancelled');
} else {
// HTTP error we threw above
console.error('Request failed:', error.message);
showUserError(error.message);
}
return null;
}
}
function showUserError(message) {
const el = document.getElementById('error-banner');
el.textContent = message;
el.style.display = 'block';
}
// Reusable fetch with timeout
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
}
}
// Usage
fetchWithTimeout('/api/data', {}, 5000)
.then(data => console.log(data))
.catch(err => console.error(err.message));
// Retry a fetch up to maxRetries times with exponential backoff
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Don't retry client errors (4xx) - only server errors (5xx) or network issues
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
if (!response.ok) throw new Error(`Server error: ${response.status}`);
return await response.json();
} catch (error) {
lastError = error;
// Don't retry client errors
if (error.message.startsWith('Client error')) throw error;
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 500; // 1s, 2s, 4s
console.warn(`Attempt ${attempt} failed. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(`All ${maxRetries} attempts failed: ${lastError.message}`);
}
// Usage
fetchWithRetry('/api/unstable-endpoint')
.then(data => console.log('Success:', data))
.catch(err => console.error('Gave up:', err.message));
Check the status before decoding the success model. An error response may use a different JSON schema, plain text, or no body at all. Read the Content-Type header or use a tolerant error parser, retain a safe message and correlation ID, and throw an application error that the UI can classify without leaking server internals.
Treat 400-series statuses according to their meaning: validation feedback belongs near fields, 401 may begin reauthentication, 403 should explain insufficient access without retrying, 404 can show a missing-resource state, and 429 should honor Retry-After when supplied. A 500-series failure may be transient, but unlimited immediate retries can worsen an outage.
AbortController lets code cancel a fetch when the user navigates away or a newer search supersedes the old one. Pass controller.signal to fetch and call abort on cleanup. Cancellation is an expected control-flow outcome, so do not display it as a scary network error.
Cancellation alone may not prevent every race if work has already completed. Track the current request identity or compare the query associated with the result before updating state. The visible result must correspond to the latest user intent, not whichever response happened to arrive last.
Keep the previous successful content visible when a background refresh fails, and label it as potentially stale when that matters. For an initial load with no usable data, show an error state with a focused retry action. Disable only the control whose request is pending rather than freezing the entire interface.
Log diagnostic context on the server and use a correlation ID in the client-facing message. Never display stack traces, SQL messages, tokens, or raw HTML returned by an upstream service. Error handling is part of the API contract and should be tested for representative statuses, malformed bodies, timeouts, and cancellation.
Try this next
0 of 2 completed
Explore 500+ free tutorials across 20+ languages and frameworks.