Frontend TestingAPI TestingTimeoutsRetriesNetwork FailuresFrontend Development

How to Test API Timeouts, Retries, and Network Failures

Successful and immediate API responses are the easiest case. Production systems also face slow upstream services, temporary outages, mobile connectivity changes, DNS failures, connection resets, gateway timeouts, rate limits, and partial failures.

This guide shows how to test API timeouts, retries, and network failures so your frontend stays resilient when the network does not cooperate.

Published August 4, 202612 min readBy MockFlow Team

Why Resilience Testing Matters

Users experience degraded networks every day. If your app only knows how to render a fast 200, it will stall, spam retries, overwrite fresh data with stale responses, or show unhelpful errors when conditions get messy. Resilience testing makes those failure modes intentional and observable before release.

Combine this work with API error response testing and the frontend API testing checklist so status handling and transport failures are both covered.

Distinguish the Failure Types

Teams often collapse every bad outcome into “the API failed.” These cases behave differently in code and UI:

  • Slow response: the request eventually succeeds or fails after a long wait.
  • Client-side timeout: your app aborts before the server finishes.
  • Server timeout: the server or gateway returns a timeout status.
  • HTTP error response: a status such as 500 is returned with headers and usually a body.
  • Network-level failure: the request never completes as HTTP.
  • Offline browser state: the client believes it has no network.
  • Aborted request: cancellation via AbortController or navigation.
  • Malformed response: transport succeeded, but the body cannot be used safely.
Important distinction: an HTTP 500 response is still a valid HTTP response. A true network failure may provide no HTTP status code at all.

Simulating Slow Responses

Configure mock response delays so loading UI gets real screen time. Useful ranges:

  • 500 ms: normal but noticeable
  • 2–3 seconds: degraded experience
  • 8–15 seconds: likely timeout territory

While the request is in flight, verify:

  • Loading indicators and skeleton states
  • Duplicate-click prevention
  • Cancellation controls
  • Navigation away during a request
  • User messaging that explains the wait or failure

For a dedicated latency guide, read why developers should simulate slow API responses during testing.

Client-Side Timeouts with AbortController

Use AbortController to enforce a client deadline and distinguish aborted requests from server errors:

export async function fetchWithTimeout(
  input: RequestInfo | URL,
  init: RequestInit = {},
  timeoutMs = 5000
) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(input, {
      ...init,
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeoutId);
  }
}

export async function loadOrders() {
  try {
    const response = await fetchWithTimeout("/api/orders", {}, 5000);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      throw new Error("Request timed out. Please try again.");
    }
    throw error;
  }
}

Mock a delay longer than your timeout to confirm the UI shows a timeout message instead of waiting forever or treating the abort as a generic 500.

Testing Retry Logic

Retries are appropriate mainly for transient failures and idempotent operations. Good practice includes:

  • Retry GET requests carefully when the failure looks transient
  • Avoid automatically retrying non-idempotent POST operations without idempotency protection
  • Use a retry limit
  • Use exponential backoff
  • Add jitter
  • Respect Retry-After
  • Do not retry permanent client errors such as most 400 responses
async function fetchWithRetry(
  input: RequestInfo,
  init: RequestInit = {},
  maxAttempts = 3
) {
  let attempt = 0;
  let lastError: unknown;

  while (attempt < maxAttempts) {
    try {
      const response = await fetch(input, init);
      if (response.status === 401 || response.status === 403 || response.status === 404) {
        return response; // do not retry these
      }
      if (response.ok || response.status < 500) {
        return response;
      }
      lastError = new Error(`HTTP ${response.status}`);
    } catch (error) {
      lastError = error;
    }

    attempt += 1;
    if (attempt >= maxAttempts) break;
    const delayMs = 250 * 2 ** (attempt - 1) + Math.floor(Math.random() * 100);
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw lastError;
}

When a response is 401, do not keep retrying the same credentials. Handle session recovery instead — see authentication and authorization testing with mock APIs.

Suggested Retry Decision Table

The table below is a starting point, not a universal rule. API-specific semantics matter.

OutcomeTypical retry?Notes
Network errorOften yesLimit attempts; show offline guidance if persistent
408Often yesRequest timed out; backoff before retry
429Yes, after waitHonor Retry-After; pause UI actions
500SometimesRetry GET carefully; avoid blind POST retries
502 / 503 / 504Often yesTransient gateway or upstream issues
400NoFix the request instead of retrying
401NoRefresh session once if supported, then login
403NoPermission problem; show access denied
404NoResource missing; offer recovery UI

Exponential Backoff and Jitter

Immediate retries can amplify an outage. If thousands of clients retry in lockstep, the recovering service gets hit again. Exponential backoff spaces attempts out. Jitter spreads them so clients do not synchronize.

function backoffDelayMs(attempt: number, baseMs = 250, maxMs = 8000) {
  const exponential = Math.min(maxMs, baseMs * 2 ** attempt);
  const jitter = Math.floor(Math.random() * baseMs);
  return exponential + jitter;
}

Testing Rate Limits

Mock a realistic 429 Too Many Requests response with a Retry-After header:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Try again in 30 seconds."
  }
}

The UI should:

  • Pause retries for the indicated duration
  • Display useful feedback instead of a generic failure
  • Avoid rapid repeated requests from impatient clicks
  • Re-enable the action at the appropriate time

Testing Gateway and Server Timeouts

Cover these status codes with mock profiles:

  • 408 Request Timeout
  • 502 Bad Gateway
  • 503 Service Unavailable
  • 504 Gateway Timeout

Tell users the service is temporarily unavailable, offer a retry when appropriate, and avoid exposing raw infrastructure details. Retries may be reasonable for these statuses on idempotent reads; confirm that POST side effects remain safe. For status-code UX patterns, see HTTP status codes every frontend developer should test.

Testing Offline Behavior

Cover browser-level offline behavior:

  • navigator.onLine
  • online and offline events
  • Failed fetch calls while disconnected
  • Cached content that remains useful offline
  • Queued user actions that sync after reconnect
  • Reconnection UX that is calm and clear

navigator.onLine is only a signal. It does not prove that a specific API is reachable. Captive portals and partial connectivity can still break requests while the browser reports online.

Testing Cancellation and Stale Requests

Common race scenarios:

  • The user changes a search query before the previous request finishes
  • The user navigates to another page mid-request
  • A slower old request finishes after a newer request
let activeController: AbortController | null = null;
let requestId = 0;

export async function searchProducts(query: string) {
  activeController?.abort();
  const controller = new AbortController();
  activeController = controller;
  const currentId = ++requestId;

  const response = await fetch(`/api/products?q=${encodeURIComponent(query)}`, {
    signal: controller.signal,
  });
  const data = await response.json();

  if (currentId !== requestId) {
    return; // stale response — ignore
  }

  return data;
}

Cancellation or request identity prevents stale data from replacing fresh results.

Testing Malformed and Partial Responses

These are not identical to network failures, but they belong in resilience testing:

  • Invalid JSON
  • Missing required fields
  • Incorrect content type
  • Truncated data
  • Empty body with a success status

Your client should fail safely, surface a recoverable error, and avoid rendering half-parsed objects as if they were complete records.

Using MockFlow for Failure Scenarios

MockFlow can currently help you simulate failure-related HTTP behavior through:

  • Response delays
  • Different status codes
  • Custom response bodies
  • Custom headers
  • Multiple response profiles

MockFlow does not disconnect the browser network stack or produce a true DNS failure. For offline mode, connection resets, and similar transport-level cases, combine MockFlow with browser DevTools, automated test tooling, or request interception.

Related guides: simulate API responses for frontend development and testing third-party API integrations before production.

Practical Resilience Checklist

  • Loading states appear and clear for slow and failed requests
  • Client timeouts abort hung requests with clear messaging
  • Cancellation works on navigation and query changes
  • Retry limits prevent infinite loops
  • Backoff and jitter are used for transient failures
  • Rate limits respect Retry-After
  • Offline states and reconnection UX are covered
  • Duplicate requests from double-clicks are blocked
  • Stale responses cannot overwrite fresher data
  • User-facing errors are actionable and non-technical
  • Observability captures timeout, retry, and abort signals

FAQ

What is the difference between a timeout and a network failure?

A timeout is your client giving up after a deadline. A network failure may never yield an HTTP status. A 500 is still an HTTP response.

Which status codes are usually safe to retry?

Network errors, 408, 429 after waiting, and many 502/503/504 cases. Avoid automatic retries for most 400, 401, 403, and 404 responses.

How do you implement a fetch timeout?

Pair AbortController with a timer, abort when the deadline hits, and handle AbortError separately from HTTP failures.

Can MockFlow simulate a true offline disconnect?

No. Use MockFlow for delays, statuses, bodies, headers, and profiles. Use DevTools or test tooling for true offline and DNS-level failures.

Should POST requests be retried automatically?

Only when idempotency is guaranteed. Otherwise retries can duplicate side effects.

Rehearse timeout and retry scenarios in MockFlow

Configure delays, 429 profiles, gateway timeouts, and custom error bodies so your frontend stays resilient before production traffic arrives.