Advanced API Error Handling in JS: Full Guide
A complete guide to advanced API error handling in JavaScript. Covers custom error classes, structured error responses, HTTP status code mapping, retry-safe vs non-retry-safe errors, global error boundaries, centralized error logging, graceful degradation, circuit breaker pattern, and user-friendly error messages.
Basic try/catch blocks around fetch calls are not enough for production applications. Real-world APIs fail in dozens of ways: network timeouts, rate limits, validation errors, auth expiration, server crashes, and malformed responses. This guide builds a robust error handling architecture that categorizes, retries, logs, and gracefully recovers from every failure type.
The Problem With Basic Error Handling
// This is NOT sufficient for production
async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
return await res.json();
} catch (err) {
console.error(err);
return null;
}
}This approach has three critical flaws: it treats all errors the same, it swallows the error context, and it silently returns null which can cause cascading bugs downstream.
Custom Error Classes
Build a hierarchy of error types so calling code can decide how to respond:
class ApiError extends Error {
constructor(message, statusCode, details = null) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.details = details;
this.timestamp = new Date().toISOString();
this.isRetryable = false;
}
}
class NetworkError extends ApiError {
constructor(message = "Network request failed") {
super(message, 0);
this.name = "NetworkError";
this.isRetryable = true;
}
}
class ValidationError extends ApiError {
constructor(message, fieldErrors) {
super(message, 422, fieldErrors);
this.name = "ValidationError";
this.isRetryable = false;
}
}
class AuthError extends ApiError {
constructor(message = "Authentication required") {
super(message, 401);
this.name = "AuthError";
this.isRetryable = false;
}
}
class RateLimitError extends ApiError {
constructor(retryAfter = 60) {
super("Too many requests", 429);
this.name = "RateLimitError";
this.retryAfter = retryAfter;
this.isRetryable = true;
}
}
class ServerError extends ApiError {
constructor(message = "Internal server error", statusCode = 500) {
super(message, statusCode);
this.name = "ServerError";
this.isRetryable = true;
}
}HTTP Status Code Mapping
| Status Code | Error Class | Retryable | Action |
|---|---|---|---|
| 0 (no response) | NetworkError | Yes | Retry with backoff |
| 400 | ApiError | No | Show validation message |
| 401 | AuthError | No | Redirect to login or refresh token |
| 403 | ApiError | No | Show permission denied |
| 404 | ApiError | No | Show not found message |
| 409 | ApiError | No | Show conflict message |
| 422 | ValidationError | No | Highlight invalid fields |
| 429 | RateLimitError | Yes | Wait Retry-After seconds |
| 500-599 | ServerError | Yes | Retry with exponential backoff |
Response Parser
async function parseErrorResponse(response) {
const statusCode = response.status;
let body = {};
try {
body = await response.json();
} catch {
body = { message: response.statusText };
}
const message = body.message || body.error || `Request failed with status ${statusCode}`;
switch (true) {
case statusCode === 401:
return new AuthError(message);
case statusCode === 422:
return new ValidationError(message, body.errors || body.fields || null);
case statusCode === 429: {
const retryAfter = parseInt(response.headers.get("Retry-After") || "60", 10);
return new RateLimitError(retryAfter);
}
case statusCode >= 500:
return new ServerError(message, statusCode);
default:
return new ApiError(message, statusCode, body.details || null);
}
}Centralized Fetch Wrapper
async function apiFetch(url, options = {}) {
let response;
try {
response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
} catch (networkErr) {
throw new NetworkError(networkErr.message);
}
if (!response.ok) {
throw await parseErrorResponse(response);
}
// Handle 204 No Content
if (response.status === 204) return null;
try {
return await response.json();
} catch {
throw new ApiError("Invalid JSON response", response.status);
}
}See how to use the JS Fetch API complete tutorial for Fetch fundamentals this wrapper builds on.
Retry Logic for Retryable Errors
async function apiFetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await apiFetch(url, options);
} catch (error) {
lastError = error;
if (!error.isRetryable || attempt === maxRetries) {
throw error;
}
if (error instanceof RateLimitError) {
await sleep(error.retryAfter * 1000);
} else {
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
await sleep(delay);
}
console.warn(`Retry ${attempt + 1}/${maxRetries} for ${url}`);
}
}
throw lastError;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}Circuit Breaker Pattern
When a service is consistently failing, stop sending requests to let it recover:
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 30000;
this.state = "CLOSED"; // CLOSED = normal, OPEN = blocking, HALF_OPEN = testing
this.failureCount = 0;
this.lastFailureTime = null;
}
async execute(fn) {
if (this.state === "OPEN") {
if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
this.state = "HALF_OPEN";
} else {
throw new ApiError("Circuit breaker is open. Service unavailable.", 503);
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = "CLOSED";
}
onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = "OPEN";
}
}
}
// Usage
const userServiceBreaker = new CircuitBreaker({ failureThreshold: 3, resetTimeout: 15000 });
async function getUser(id) {
return userServiceBreaker.execute(() => apiFetchWithRetry(`/api/users/${id}`));
}Circuit Breaker States
| State | Behavior | Transition |
|---|---|---|
| CLOSED | All requests pass through normally | Moves to OPEN after N consecutive failures |
| OPEN | All requests immediately rejected (no network call) | Moves to HALF_OPEN after timeout expires |
| HALF_OPEN | One test request allowed through | Moves to CLOSED on success, OPEN on failure |
Centralized Error Logger
class ErrorLogger {
static log(error, context = {}) {
const entry = {
name: error.name,
message: error.message,
statusCode: error.statusCode,
isRetryable: error.isRetryable,
timestamp: error.timestamp || new Date().toISOString(),
url: context.url || "unknown",
method: context.method || "unknown",
userId: context.userId || null,
stack: error.stack,
};
// Console in development
if (process.env.NODE_ENV !== "production") {
console.error("[API Error]", entry);
return;
}
// Send to monitoring service in production
navigator.sendBeacon("/api/logs/error", JSON.stringify(entry));
}
}
// Integrate with fetch wrapper
async function apiFetch(url, options = {}) {
try {
// ... fetch logic from above
} catch (error) {
ErrorLogger.log(error, { url, method: options.method || "GET" });
throw error;
}
}User-Friendly Error Messages
function getUserMessage(error) {
if (error instanceof NetworkError) {
return "Unable to connect. Please check your internet connection and try again.";
}
if (error instanceof AuthError) {
return "Your session has expired. Please log in again.";
}
if (error instanceof ValidationError) {
return "Please correct the highlighted fields and try again.";
}
if (error instanceof RateLimitError) {
return `Too many requests. Please wait ${error.retryAfter} seconds.`;
}
if (error instanceof ServerError) {
return "Something went wrong on our end. Please try again in a few minutes.";
}
return "An unexpected error occurred. Please try again.";
}Putting It All Together
async function handleFormSubmit(formData) {
const statusEl = document.getElementById("status");
const submitBtn = document.getElementById("submit");
submitBtn.disabled = true;
statusEl.textContent = "Submitting...";
statusEl.className = "status info";
try {
const result = await apiFetchWithRetry("/api/submit", {
method: "POST",
body: JSON.stringify(formData),
});
statusEl.textContent = "Submitted successfully!";
statusEl.className = "status success";
return result;
} catch (error) {
statusEl.textContent = getUserMessage(error);
statusEl.className = "status error";
if (error instanceof ValidationError && error.details) {
highlightFields(error.details);
}
if (error instanceof AuthError) {
setTimeout(() => window.location.href = "/auth", 2000);
}
} finally {
submitBtn.disabled = false;
}
}Rune AI
Key Insights
- Custom error classes enable type-safe handling: Use
instanceofchecks to branch logic cleanly instead of parsing status codes in every catch block - Only retry transient errors: Network failures, 429, and 5xx are retryable; 4xx errors (except 429) indicate permanent client-side issues
- Circuit breakers prevent cascading failures: Stop calling a failing service after N failures and auto-recover after a cooldown period
- Separate user messages from developer logs: Log the full error with stack traces for debugging; show friendly messages to users
- Centralize error parsing: One function that maps HTTP responses to error classes keeps error handling consistent across the entire application
Frequently Asked Questions
Should I use Axios error handling or build my own with Fetch?
When should I use the circuit breaker pattern?
How do I handle errors in Promise.all?
Should I retry 401 errors?
Conclusion
Advanced API error handling requires custom error classes for type-safe branching, a centralized fetch wrapper that maps HTTP status codes to error types, retry logic that only retries transient failures, circuit breakers that prevent cascading failures, centralized logging for production monitoring, and user-friendly messages for the UI. For the Fetch API foundation, see handling POST requests with JS Fetch API guide. For the event loop mechanics behind async error propagation, see the JS event loop architecture complete guide.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.