Advanced API Error Handling in JS: Full Guide

Go beyond try...catch with production-grade API error handling. Learn error categorization, circuit breakers, fallback strategies, error reporting, and structured error responses.

8 min read

Basic try...catch handles individual requests. Advanced API error handling means building a system that categorizes failures, protects struggling services, degrades gracefully, and reports problems so your team knows about them.

The goal: your app keeps working, or at least fails informatively, no matter what the network or server does.

A single try...catch around one fetch call is fine for a form submission or a one-off request. It falls apart once an app makes dozens of API calls across dozens of components: every one of those call sites ends up with its own slightly different error handling, some retry on failure, some do not, and none of them talk to each other.

A failing endpoint being hammered by five separate components has no coordinated response. The patterns in this guide fix that by moving the decisions, what counts as retryable, how long to back off, when to stop calling a dead service, into shared, reusable pieces that every call site inherits automatically.

Categorizing errors

Not all errors are the same. Building a category system lets each error type trigger the right response. A custom error class carries that category alongside the usual message, so code further up the call stack can branch on it directly.

javascriptjavascript
class ApiError extends Error {
  constructor(message, { status, url, category, originalError }) {
    super(message);
    this.name = "ApiError";
    this.status = status;
    this.url = url;
    this.category = category;
    this.originalError = originalError;
    this.timestamp = new Date().toISOString();
  }
}

The categorization logic itself checks the error and response together, since a network failure never has a response while an HTTP error always does. Checking the error argument first also matters for AbortController-based cancellation: an aborted request throws before a response ever exists, so it has to be caught before the code even looks at the response argument.

javascriptjavascript
function categorizeError(error, response, url) {
  if (error && error.name === "TypeError") return "network";
  if (error && error.name === "AbortError") return "cancellation";
  if (!response) return "unknown";
 
  const status = response.status;
  if (status === 401 || status === 403) return "auth";
  if (status === 404) return "notFound";
  if (status === 422) return "validation";
  if (status === 429) return "rateLimit";
  if (status >= 400 && status < 500) return "client";
  if (status >= 500) return "server";
  return "unknown";
}
CategoryExampleTypical response
networkDNS failure, connection refusedRetry with backoff, show offline message
cancellationAbortController.abort()Ignore silently
auth401, 403Redirect to login, refresh token
notFound404Show "not found" UI, do not retry
validation422Show field-level errors from response body
rateLimit429Retry after Retry-After delay
client400, 405, 409Show error message, do not retry
server500, 502, 503Retry with backoff

Categorization drives every decision that follows: whether to retry, what message to show, and whether to log the error.

These eight categories cover most REST APIs, but nothing stops you from adding your own. An app with a payments API might add a declined category for a card rejection, which should never retry and always shows a specific message, separate from a generic client error. The important part is not the exact list, it is that every failure gets sorted into a category before anything decides what to do about it, instead of scattering status-code checks across the codebase.

Building a centralized fetch wrapper

Instead of scattering error handling across every API call, build one wrapper that every request goes through. Breaking it into small pieces keeps each concern readable on its own, starting with turning the wrapper's custom options into plain fetch options.

javascriptjavascript
function buildFetchOptions(options, signal) {
  const fetchOptions = { ...options, signal };
  delete fetchOptions.retries;
  delete fetchOptions.timeout;
  delete fetchOptions.onRetry;
  return fetchOptions;
}

Next, a helper turns a failed HTTP response into an ApiError and reads its body once, since the body can only be consumed a single time.

javascriptjavascript
async function readErrorResponse(response, url) {
  const category = categorizeError(null, response, url);
  const errorBody = await response.json().catch(() => ({}));
  return new ApiError(
    errorBody.message || `Request failed with status ${response.status}`,
    { status: response.status, url, category }
  );
}

A small helper decides how long to wait before the next attempt, preferring the server's own Retry-After value over the calculated backoff whenever the category is a rate limit.

javascriptjavascript
function computeRetryDelay(category, response, attempt) {
  return category === "rateLimit"
    ? (parseInt(response.headers.get("Retry-After"), 10) || 5) * 1000
    : Math.pow(2, attempt) * 1000;
}

The response-handling branch ties those three helpers together: read the error, decide whether it is worth retrying, and wait if so.

javascriptjavascript
async function handleHttpError(response, url, attempt, retries, onRetry) {
  const apiError = await readErrorResponse(response, url);
  if (apiError.category !== "server" && apiError.category !== "rateLimit") throw apiError;
  if (attempt === retries) throw apiError;
 
  onRetry?.(attempt + 1, retries, apiError);
  await new Promise((resolve) => setTimeout(resolve, computeRetryDelay(apiError.category, response, attempt)));
}

A matching branch handles the case where fetch itself rejects rather than resolving with a response, distinguishing a real network error from a timeout-triggered abort so each gets its own retry decision and category.

javascriptjavascript
async function handleFetchFailure(error, url, attempt, retries, onRetry) {
  if (error instanceof ApiError) throw error;
  if (error.name === "AbortError") throw new ApiError("Request timed out", { status: 0, url, category: "timeout" });
 
  const category = categorizeError(error, null, url);
  if (attempt === retries || category !== "network") {
    throw new ApiError(error.message, { status: 0, url, category, originalError: error });
  }
  onRetry?.(attempt + 1, retries, { message: error.message });
  await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}

A single attempt wraps one fetch call: it clears the timeout as soon as a response arrives, returns the parsed body on success, and otherwise defers to the two error helpers above.

javascriptjavascript
async function attemptRequest(url, fetchOptions, timeoutId, attempt, retries, onRetry) {
  try {
    const response = await fetch(url, fetchOptions);
    clearTimeout(timeoutId);
    if (response.ok) return await response.json();
    await handleHttpError(response, url, attempt, retries, onRetry);
  } catch (error) {
    clearTimeout(timeoutId);
    await handleFetchFailure(error, url, attempt, retries, onRetry);
  }
  return undefined;
}

The wrapper itself just loops over attempts, starting a fresh timeout each time and stopping as soon as an attempt returns real data instead of undefined.

javascriptjavascript
async function apiClient(url, options = {}) {
  const { retries = 2, timeout = 10000, onRetry = null } = options;
  const controller = new AbortController();
  const fetchOptions = buildFetchOptions(options, controller.signal);
 
  for (let attempt = 0; attempt <= retries; attempt += 1) {
    const timeoutId = setTimeout(() => controller.abort(), timeout);
    const result = await attemptRequest(url, fetchOptions, timeoutId, attempt, retries, onRetry);
    if (result !== undefined) return result;
  }
}

Callers never see the retry loop or the AbortController. A caught ApiError just needs its category checked to decide what the UI should do, so that branching logic belongs in its own small function too.

javascriptjavascript
function handleApiError(error) {
  if (!(error instanceof ApiError)) throw error;
 
  switch (error.category) {
    case "auth": return redirectToLogin();
    case "notFound": return showNotFound();
    case "network": return showOfflineBanner();
    default: return showErrorToast(error.message);
  }
}

Now every API call in your app follows the same shape: call apiClient, and hand any thrown error to handleApiError.

javascriptjavascript
try {
  const user = await apiClient(`/api/users/${id}`);
  renderUser(user);
} catch (error) {
  handleApiError(error);
}

One wrapper, consistent behavior everywhere.

This wrapper already handles two very different failure timelines. A single request can fail once and succeed on retry a moment later; that is what the retry loop inside apiClient is for. A service can also fail consistently for minutes at a time, in which case retrying every single call just adds latency without ever succeeding.

The retry loop alone cannot tell these two situations apart, since from inside one request it looks the same either way. That is the gap the circuit breaker pattern closes: it tracks failures across calls, not within one call, so it can recognize when a service is down for longer than a single retry window would ever catch.

Circuit breaker pattern

A circuit breaker stops calling a failing endpoint after a threshold of consecutive failures. It gives the server time to recover and avoids wasting client resources on doomed requests.

Consider a checkout page that calls a recommendations service to show "customers also bought" suggestions. If that service goes down, every page load still waits out the retry logic in apiClient before falling back, adding seconds of delay to a page where recommendations are not even essential. A circuit breaker recognizes the pattern of repeated failures and starts failing fast instead, so the checkout page moves on immediately.

Circuit breaker state machine

The circuit has three states. Closed: requests flow normally. Open: requests fail immediately without calling the server. Half-open: a single test request is allowed through to check if the server recovered.

A small helper checks whether the breaker should currently block calls, flipping it to half-open once the reset timeout has passed.

javascriptjavascript
function isBlocked(breaker) {
  if (breaker.state !== "open") return false;
  if (Date.now() - breaker.lastFailureTime >= breaker.resetTimeout) {
    breaker.state = "half-open";
    return false;
  }
  return true;
}

A failure bumps the count and, once the threshold is crossed (or a half-open test call fails), flips the breaker back to open.

javascriptjavascript
function recordFailure(breaker) {
  breaker.failureCount += 1;
  breaker.lastFailureTime = Date.now();
  if (breaker.state === "half-open" || breaker.failureCount >= breaker.failureThreshold) {
    breaker.state = "open";
  }
}

The function that actually runs the call ties the two helpers together: block if the circuit is open, reset the count on success, and record a failure otherwise.

javascriptjavascript
async function runThroughBreaker(breaker, fn) {
  if (isBlocked(breaker)) throw new Error("Circuit is open. Requests are blocked.");
 
  try {
    const result = await fn();
    breaker.failureCount = 0;
    breaker.state = "closed";
    return result;
  } catch (error) {
    recordFailure(breaker);
    throw error;
  }
}

The class itself just holds state and delegates to that helper, which keeps the state-machine logic testable on its own. Keeping isBlocked and recordFailure as plain functions that take the breaker's state as an explicit argument, rather than methods tangled into the class, means you can call and assert on them directly in a test without constructing a real CircuitBreaker instance or waiting on real timers.

javascriptjavascript
class CircuitBreaker {
  constructor({ failureThreshold = 5, resetTimeout = 30000 }) {
    this.failureThreshold = failureThreshold;
    this.resetTimeout = resetTimeout;
    this.failureCount = 0;
    this.lastFailureTime = null;
    this.state = "closed"; // closed | open | half-open
  }
 
  call(fn) {
    return runThroughBreaker(this, fn);
  }
}

Use it to protect a specific endpoint by wrapping the call that talks to that endpoint, so failures there cannot cascade into the rest of the app.

javascriptjavascript
const userServiceBreaker = new CircuitBreaker({
  failureThreshold: 3,
  resetTimeout: 10000, // 10 seconds
});
 
async function getUser(id) {
  return userServiceBreaker.call(() =>
    apiClient(`/api/users/${id}`)
  );
}

After three consecutive failures, the circuit opens. For the next 10 seconds, every call to getUser throws immediately without hitting the network. After 10 seconds, one test request goes through. If it succeeds, the circuit closes and normal operation resumes.

The two numbers you tune are failureThreshold and resetTimeout, and they trade off against each other. A low threshold like 2 or 3 trips the breaker fast, which protects a struggling service but also means a couple of unlucky timeouts, maybe from a brief network hiccup on the user's own connection rather than the server, can block an endpoint that was actually fine.

A short resetTimeout retests sooner but risks hammering a service that has not actually recovered yet. There is no universal correct value; a background sync endpoint can tolerate a long cooldown, while something on the critical path of checkout usually wants a shorter one so users are not blocked for long.

Fallback strategies

When an API call fails and you cannot show an error screen, fall back to something usable. Three strategies cover most cases: reuse stale data, return a safe default, or degrade part of the page instead of all of it.

Which one applies depends on how important the data is. A stale cache works well for anything that changes slowly, like a user's profile. A default value fits data that is genuinely optional, like a notification count. Neither is appropriate for a write, such as submitting a payment: falling back silently there would hide a failure the user actually needs to see.

Stale cache. Show the last successful response while retrying in the background. When a fresh request fails, a small helper decides whether cached data can stand in for it, and throws only if nothing is cached yet.

javascriptjavascript
function getCachedOrThrow(cache, id, error) {
  const cached = cache.get(id);
  if (cached) {
    console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleTimeString()}`);
    return cached.data;
  }
  throw error;
}

The function that fetches data just records every successful response in the cache and hands failures off to that helper.

javascriptjavascript
const cache = new Map();
 
async function getUserWithFallback(id) {
  try {
    const user = await apiClient(`/api/users/${id}`);
    cache.set(id, { data: user, timestamp: Date.now() });
    return user;
  } catch (error) {
    return getCachedOrThrow(cache, id, error);
  }
}

Default values. Return a sensible default when the data is not critical, such as an empty notifications list instead of a broken page.

javascriptjavascript
async function getNotifications() {
  try {
    return await apiClient("/api/notifications");
  } catch (error) {
    if (error.category === "server" || error.category === "network") {
      return []; // Empty list is better than a broken page
    }
    throw error;
  }
}

Degraded experience. Show a simplified version of the page instead of failing outright. If the profile call succeeds but analytics fails with a server error, a helper still returns the profile, just marked as degraded.

javascriptjavascript
async function loadDegraded(error) {
  if (error.category !== "server") throw error;
  const user = await apiClient("/api/profile");
  return { user, analytics: null, degraded: true };
}

The main loader tries both calls together first, and only falls back to the degraded path when something goes wrong.

javascriptjavascript
async function loadDashboard() {
  try {
    const [user, analytics] = await Promise.all([
      apiClient("/api/profile"),
      apiClient("/api/analytics"),
    ]);
    return { user, analytics, degraded: false };
  } catch (error) {
    return loadDegraded(error);
  }
}

The page loads with a note that analytics are temporarily unavailable. The user is not blocked.

Notice that all three strategies check error.category before deciding what to do, rather than catching every failure the same way. A validation error or a 404 usually means the request itself was wrong, and falling back would just hide that mistake from you during development. Fallbacks belong on the categories you expect to be transient, network and server, not on categories that indicate a bug in the request.

Fallbacks and circuit breakers solve different problems and combine well. A circuit breaker decides when to stop calling a struggling service; a fallback decides what to show once you have stopped. The ResilientApiClient built later in this guide wires both together: a cached value or caller-provided default is used whenever the breaker for that endpoint is open, so a known-failing service never leaves the UI with nothing to render.

It is worth being deliberate about which endpoints get a fallback at all. Wrapping every single API call in a try block that swallows errors and returns a default is tempting, but it also hides real bugs.

A typo in a URL now silently returns an empty array instead of a clear 404 in your logs. Reserve fallbacks for endpoints where you have already decided, ahead of time, what a reasonable degraded state looks like.

Centralized error logging

Every error should be logged with context. A centralized logger gives you visibility into what is failing and why. Building the log entry is separate from recording it, since the same entry shape is useful whether you print it, store it, or ship it elsewhere.

javascriptjavascript
function buildErrorEntry(error, context) {
  return {
    timestamp: new Date().toISOString(),
    message: error.message,
    category: error.category || "unknown",
    status: error.status || null,
    url: error.url || context.url || null,
    stack: error.stack,
    ...context,
  };
}

logError uses that helper to build the entry, keeps it in memory for later inspection, and prints it. In a real app, the comment marks where you would forward the entry to a monitoring service like Sentry.

javascriptjavascript
const errorLog = [];
 
function logError(error, context = {}) {
  const entry = buildErrorEntry(error, context);
  errorLog.push(entry);
  console.error(`[${entry.category}] ${entry.message}`, entry);
  // Forward to your monitoring provider here, e.g. Sentry.captureException(entry)
}

Wiring that logger into the fetch wrapper means every request failure gets recorded automatically, without each caller having to remember to log it.

javascriptjavascript
// Inside the apiClient wrapper's catch block:
try {
  // ... fetch logic
} catch (error) {
  logError(error, { attempt, url });
  throw error;
}

Good error logs answer three questions: what failed, when did it fail, and what was the context. Include the URL, status code, timestamp, and category. For network errors, include whether the client was online.

A structured object, rather than a plain string message, is what makes those logs searchable later. When every entry has the same shape, category, status, url, timestamp, a monitoring dashboard can group by category to show that ninety percent of today's errors are timeouts against one specific endpoint, instead of a scroll of unrelated text lines that someone has to read one at a time to spot the pattern.

The in-memory array here is enough for a tutorial, but a production app usually sends errorLog entries to a monitoring service like Sentry, Datadog, or a self-hosted log aggregator, since browser tabs close before anyone reads console output. On a high-traffic app, logging every single failed request can also be more data than you need; many teams sample non-critical categories like cancellation while always logging server and auth errors in full, since those are the ones that indicate a real problem worth investigating.

Handling errors at the UI layer

The fetch wrapper categorizes errors. The UI layer decides what to show, using a lookup table so each category maps to one clear, non-technical sentence.

javascriptjavascript
const ERROR_MESSAGES = {
  network: "No internet connection. Check your network and try again.",
  timeout: "The server took too long to respond. Please try again.",
  auth: "Your session has expired. Please log in again.",
  notFound: "The requested resource was not found.",
  validation: null, // Use the server's own field-level message
  rateLimit: "Too many requests. Please wait a moment and try again.",
  server: "The server encountered an error. Our team has been notified.",
  cancellation: null, // No message for cancellations
};

getErrorMessage falls back to the raw error.message for any category, like validation, that has no fixed sentence in the table above, since a server's field-level validation text is usually specific enough to show directly.

javascriptjavascript
function getErrorMessage(error) {
  if (!(error instanceof ApiError)) {
    return "An unexpected error occurred. Please try again.";
  }
  return ERROR_MESSAGES[error.category] || error.message;
}

Never show raw error messages from the server directly to the user. A 500 error body might contain a stack trace, database query, or internal path. Map categories to user-friendly messages, and treat this table as the one place in the app where that mapping lives, so a copywriter or designer can revise the wording without touching any fetch logic.

Where that message ends up also matters. A network or server error is usually global, since it affects the whole page, so a toast or banner works well. A validation error is local to the specific field that failed and should appear next to that field, not in a toast the user has to mentally connect back to a form. Reusing the same getErrorMessage function for both keeps the wording consistent even when the presentation differs.

Combining all patterns into one resilient client

The final piece combines categorization, retries, circuit breaking, and fallbacks into a single client. A small helper decides what to return when a call fails: a caller-provided fallback, a cached value, or the error itself.

javascriptjavascript
function resolveFailure(error, fallback, cache, path) {
  if (fallback !== null) return fallback;
  if (cache.has(path)) return cache.get(path).data;
  throw error;
}

A plain function runs one request through a given breaker and cache, so the class method itself only has to wire the right instances together. Passing the breaker and cache in as arguments, instead of reaching for this inside the function, is what keeps fetchThroughBreaker reusable outside the class, for example in a test that exercises it against a fake breaker without ever constructing a ResilientApiClient.

javascriptjavascript
async function fetchThroughBreaker(breaker, cache, path, url, { useCache, fallback }) {
  return breaker.call(async () => {
    try {
      const data = await apiClient(url);
      if (useCache) cache.set(path, { data, timestamp: Date.now() });
      return data;
    } catch (error) {
      return resolveFailure(error, fallback, cache, path);
    }
  });
}

One circuit breaker guards each endpoint path, created lazily the first time that path is requested rather than upfront for every possible endpoint.

javascriptjavascript
function getOrCreateBreaker(breakers, endpoint) {
  if (!breakers.has(endpoint)) {
    breakers.set(endpoint, new CircuitBreaker({ failureThreshold: 3, resetTimeout: 15000 }));
  }
  return breakers.get(endpoint);
}

The class itself just holds the maps and wires them together: build the full URL, get or create the right breaker, and delegate to fetchThroughBreaker.

javascriptjavascript
class ResilientApiClient {
  constructor({ baseUrl = "" } = {}) {
    this.baseUrl = baseUrl;
    this.circuitBreakers = new Map();
    this.cache = new Map();
  }
 
  get(path, options = {}) {
    const url = `${this.baseUrl}${path}`;
    const breaker = getOrCreateBreaker(this.circuitBreakers, path);
    return fetchThroughBreaker(breaker, this.cache, path, url, options);
  }
}

Every API call in the app now goes through the same instance, so a single get call automatically benefits from retries, circuit breaking, caching, and fallbacks without any of that logic being repeated at the call site.

javascriptjavascript
const api = new ResilientApiClient({ baseUrl: "/api" });
 
const user = await api.get(`/users/${id}`);
const notifications = await api.get("/notifications", { fallback: [] });
const config = await api.get("/config", { useCache: true });

This client automatically retries transient errors, opens circuits on repeated failures, caches responses when asked, and returns fallback values when available. Every API call inherits this behavior with a single method call.

The version here only implements get, but the same pieces extend to writes with one caveat: retries and circuit breaking are safe to apply to any request, while a fallback for a failed write should almost never silently substitute a fake success. A POST that creates an order either succeeded or it did not, and the caller needs to know which.

Metrics are the other piece production teams add on top of this. Counting how often each circuit breaker opens, and how long requests spend waiting in the retry loop before succeeding, turns this from error handling you hope is working into something you can actually verify against real traffic.

Testing this kind of resilience logic does not require a real flaky server or an actual unreliable network connection. Mock fetch to reject a fixed number of times before succeeding, and assert that the circuit opens after failureThreshold rejections and that calls during that window fail immediately without another fetch happening. Mocking Date.now lets you jump forward past resetTimeout without an actual test waiting seconds for a timer to elapse. Testing the state transitions directly, closed to open to half-open to closed, catches most bugs in this kind of logic long before it reaches production traffic.

For the fundamentals of async error handling with try...catch, see Handling Async Errors with Try Catch in JS Guide. For retry-specific patterns, see API Retry Patterns in JavaScript: Full Tutorial.

Rune AI

Rune AI

Key Insights

  • Categorize errors into network, server, client, and parse errors. Each category needs a different response.
  • A circuit breaker stops requests to a failing endpoint after a threshold, then tests periodically.
  • Fallback strategies let your app degrade gracefully: cached data, defaults, or reduced functionality.
  • Centralize error handling in a shared fetch wrapper so every API call benefits from the same logic.
  • Log structured error data with context (URL, status, timestamp) for debugging and monitoring.
RunePowered by Rune AI

Frequently Asked Questions

What is a circuit breaker pattern?

A circuit breaker stops making requests to a failing service for a cooling-off period after a threshold of consecutive failures. It prevents a client from hammering an already-down service and lets it recover.

When should I use a fallback instead of retrying?

Use a fallback when the data is not critical: show cached data, a default value, or a degraded experience. Retry when the data is critical and the failure is likely transient. Often you combine both: retry first, then fall back.

How do I centralize error handling across my application?

Create a shared fetch wrapper that handles response checking, error categorization, retries, and logging. Every API call in your app goes through this wrapper, so error handling is consistent.

Conclusion

Advanced error handling is about more than catching rejections. It means categorizing errors so the right response happens automatically, protecting failing services with circuit breakers, falling back gracefully when data is unavailable, and reporting failures so your team knows when things break.