How to Simulate API Errors & Timeouts for Testing

May 16, 2026 · 14 min read

Your API works perfectly in development. Every request returns 200 OK, the data loads instantly, and your UI looks flawless. Then you ship to production and a third-party service goes down, a database query takes 30 seconds, or your CDN starts returning 503s. Your app? White screen. No error message. No retry. Just silence.

This is the most common failure mode in modern frontend applications: untested error paths. The fix is straightforward but systematically overlooked. You need to simulate API errors, timeouts, and degraded network conditions before your users encounter them. This guide covers every failure mode, gives you production-ready code patterns, and includes an interactive simulator to generate the exact mock code you need.

Why You Need to Test Error Handling

Every production API fails. The question is not if but when and how. According to incident reports from major cloud providers, the average web application experiences API failures roughly 0.1-1% of the time. For an app making 10,000 API calls per day, that translates to 10-100 failures daily. If any of those failures produce a broken UI, your users see it.

Testing only the happy path means you are testing only the scenario that needs the least testing. The real complexity lives in the failure modes:

Each of these failures requires different handling code. If you have not tested them, you do not know if your code works. Kappafy's mock API generator helps you set up the test data. This guide helps you break it on purpose.

Types of API Failures and HTTP Status Codes

Understanding the taxonomy of API failures is essential before you start simulating them. Each failure type has different characteristics, different HTTP status codes, and requires different frontend handling strategies.

Failure Type Status Code Behavior Retry Safe?
TIMEOUT N/A (aborted) Server never responds within the allowed time window Yes
500 500 Internal Server Error — generic server-side crash Yes (with backoff)
503 503 Service Unavailable — server is overloaded or in maintenance Yes (check Retry-After)
429 429 Too Many Requests — rate limit exceeded Yes (respect Retry-After)
NETWORK N/A (no response) DNS failure, connection refused, offline — fetch() rejects Yes (if transient)
SLOW 200 (delayed) Server responds correctly but takes 5-30+ seconds N/A (succeeds)

Notice that timeouts and network errors do not produce HTTP responses at all. This is a critical distinction. Code that only checks response.status will never handle these cases because the response object does not exist. You need both a .then() handler for HTTP errors and a .catch() handler for network-level failures.

Interactive API Error Simulator

Configure the failure type below, adjust the parameters, and generate working mock code. Use the Run Live Demo button to trigger the simulated failure directly in your browser and see exactly what your error handling would receive.

API Error Simulator

3000 ms
100%
// Select an error type and click "Generate Code"

Frontend Error Handling Patterns

Once you can simulate failures, you need patterns to handle them. These are the three most important patterns for production frontend applications, ordered from simplest to most robust.

Pattern 1: Retry with Exponential Backoff

The simplest and most universally useful pattern. When a request fails with a retryable error (5xx, timeout, network error), wait a short time and try again. Each subsequent retry waits longer, preventing you from overwhelming an already-stressed server.

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(
        () => controller.abort(),
        options.timeout || 10000
      );

      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });
      clearTimeout(timeoutId);

      if (response.status === 429) {
        const retryAfter = response.headers.get('Retry-After');
        const waitMs = retryAfter
          ? parseInt(retryAfter) * 1000
          : Math.pow(2, attempt) * 1000;
        await new Promise(r => setTimeout(r, waitMs));
        continue;
      }

      if (response.status >= 500 && attempt < maxRetries) {
        await new Promise(r =>
          setTimeout(r, Math.pow(2, attempt) * 1000)
        );
        continue;
      }

      return response;
    } catch (err) {
      if (attempt === maxRetries) throw err;
      await new Promise(r =>
        setTimeout(r, Math.pow(2, attempt) * 1000)
      );
    }
  }
}

This function handles timeouts via AbortController, respects Retry-After headers from 429 responses, applies exponential backoff for server errors, and re-throws on network errors after all retries are exhausted. It covers roughly 90% of the error handling you will ever need.

Pattern 2: Circuit Breaker

When an API is consistently failing, retrying every request wastes resources and adds latency. A circuit breaker tracks failures and "opens" when the failure rate exceeds a threshold, immediately rejecting requests without attempting them. After a cooldown period, it lets one request through to test if the service has recovered.

class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000;
    this.state = 'CLOSED';   // CLOSED | OPEN | HALF_OPEN
    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 Error('Circuit breaker is OPEN — request rejected');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }
}

// Usage
const breaker = new CircuitBreaker({
  failureThreshold: 5,
  resetTimeout: 30000
});

try {
  const data = await breaker.execute(() =>
    fetchWithRetry('https://api.example.com/users')
  );
} catch (err) {
  // Show cached data or fallback UI
}

The circuit breaker is especially valuable when you depend on third-party APIs. If a payment provider is down, you do not want every page load to hang for 10 seconds waiting for a timeout. The circuit breaker fails immediately and shows a fallback.

Pattern 3: Fallback Data

The most user-friendly pattern. When an API fails, show stale data from a cache rather than an error message. Users prefer slightly outdated data to a broken page.

async function fetchWithFallback(url, cacheKey) {
  try {
    const response = await fetchWithRetry(url);
    const data = await response.json();

    // Cache the successful response
    localStorage.setItem(cacheKey, JSON.stringify({
      data,
      timestamp: Date.now()
    }));

    return { data, source: 'live' };
  } catch (err) {
    // Try to serve cached data
    const cached = localStorage.getItem(cacheKey);
    if (cached) {
      const { data, timestamp } = JSON.parse(cached);
      return {
        data,
        source: 'cache',
        age: Date.now() - timestamp
      };
    }

    // No cache available — surface the error
    throw err;
  }
}

Combine all three patterns for maximum resilience: retry first, trip the circuit breaker if retries keep failing, and serve cached fallback data while the circuit is open.

Testing Strategies Per Error Type

Each failure type requires a specific testing strategy. Here is how to approach each one methodically.

Testing Timeouts

Timeouts are the hardest failure to test because they require waiting. The key insight is that you do not need to wait for a real timeout — you can simulate one by creating a fetch that never resolves and an AbortController that fires immediately.

// Test: timeout triggers abort and shows error UI
function createTimeoutMock(timeoutMs) {
  return function mockFetch() {
    return new Promise((resolve, reject) => {
      const controller = new AbortController();
      const timer = setTimeout(() => {
        controller.abort();
        reject(new DOMException('The operation was aborted.', 'AbortError'));
      }, timeoutMs);

      // Simulate a request that never completes
      // The abort will fire before this resolves
      controller.signal.addEventListener('abort', () => {
        clearTimeout(timer);
      });
    });
  };
}

Test that your UI shows a timeout message, offers a retry button, and does not leave any dangling loading states. A common bug: the loading spinner keeps spinning after a timeout because the catch block does not reset the loading flag.

Testing 500 Errors

Server errors are the easiest to simulate because they follow the normal HTTP response pattern. The key is to test the response body as well as the status code, because real 500 errors often return HTML error pages rather than JSON.

// Simulate a 500 that returns HTML (like nginx default error page)
function mock500() {
  return Promise.resolve(new Response(
    '<html><body><h1>500 Internal Server Error</h1></body></html>',
    {
      status: 500,
      statusText: 'Internal Server Error',
      headers: { 'Content-Type': 'text/html' }
    }
  ));
}

// Test: your JSON parser should not crash on HTML responses
try {
  const response = await mock500();
  const data = await response.json(); // This throws!
} catch (err) {
  // SyntaxError: Unexpected token '<'
  // Your code needs to handle this gracefully
}

This catches a very common production bug: code that calls response.json() without first checking response.ok or the Content-Type header. The JSON parse throws a SyntaxError, which often produces a completely unhelpful error message in the UI.

Testing 429 Rate Limits

Rate limit testing requires verifying that your code respects the Retry-After header and does not retry immediately. The most important test: confirm your code does NOT hammer the server after receiving a 429.

function mock429(retryAfterSeconds = 5) {
  return Promise.resolve(new Response(
    JSON.stringify({
      error: 'Too Many Requests',
      message: 'Rate limit exceeded. Try again later.',
      retryAfter: retryAfterSeconds
    }),
    {
      status: 429,
      headers: {
        'Content-Type': 'application/json',
        'Retry-After': String(retryAfterSeconds),
        'X-RateLimit-Limit': '100',
        'X-RateLimit-Remaining': '0',
        'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + retryAfterSeconds)
      }
    }
  ));
}

Testing Network Errors

Network errors are fundamentally different from HTTP errors because there is no Response object at all. The fetch() promise rejects with a TypeError. This is the failure mode that catches the most developers off guard.

function mockNetworkError() {
  return Promise.reject(new TypeError('Failed to fetch'));
}

// In your test:
// Verify that your catch block handles TypeError specifically
// Verify that you show "Check your internet connection" (not "500 error")
// Verify that offline state is tracked globally

Testing Slow Responses

Slow responses test your loading states, skeleton screens, and user patience. The request eventually succeeds, so error handling is not the issue — UX during the wait is.

function mockSlowResponse(data, delayMs = 8000) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(new Response(JSON.stringify(data), {
        status: 200,
        headers: { 'Content-Type': 'application/json' }
      }));
    }, delayMs);
  });
}

Test that: (1) a loading indicator appears within 100ms, (2) the loading indicator is accessible (has an ARIA label), (3) the user can cancel the request, and (4) navigating away does not cause a "setState on unmounted component" error.

Complete Code Examples in JavaScript & TypeScript

Here is a production-ready API client that combines all the patterns above. This is the code you can copy directly into your project.

TypeScript: Resilient API Client

interface FetchOptions extends RequestInit {
  timeout?: number;
  retries?: number;
  fallbackData?: unknown;
  cacheKey?: string;
}

interface ApiResponse<T> {
  data: T;
  source: 'live' | 'cache' | 'fallback';
  status: number;
  retryCount: number;
}

async function resilientFetch<T>(
  url: string,
  options: FetchOptions = {}
): Promise<ApiResponse<T>> {
  const {
    timeout = 10000,
    retries = 3,
    fallbackData,
    cacheKey,
    ...fetchOptions
  } = options;

  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);

      const response = await fetch(url, {
        ...fetchOptions,
        signal: controller.signal
      });
      clearTimeout(timeoutId);

      if (!response.ok) {
        if (response.status === 429) {
          const retryAfter = response.headers.get('Retry-After');
          const waitMs = retryAfter
            ? parseInt(retryAfter) * 1000
            : Math.min(Math.pow(2, attempt) * 1000, 30000);
          await delay(waitMs);
          continue;
        }
        if (response.status >= 500 && attempt < retries) {
          await delay(Math.pow(2, attempt) * 1000);
          continue;
        }
        throw new ApiError(response.status, await response.text());
      }

      const data: T = await response.json();

      if (cacheKey) {
        localStorage.setItem(cacheKey, JSON.stringify({
          data, timestamp: Date.now()
        }));
      }

      return { data, source: 'live', status: 200, retryCount: attempt };

    } catch (err) {
      lastError = err as Error;
      if (attempt < retries && isRetryable(err)) {
        await delay(Math.pow(2, attempt) * 1000);
        continue;
      }
    }
  }

  // All retries exhausted — try cache
  if (cacheKey) {
    const cached = localStorage.getItem(cacheKey);
    if (cached) {
      const { data } = JSON.parse(cached);
      return {
        data: data as T,
        source: 'cache',
        status: 0,
        retryCount: retries
      };
    }
  }

  // Try static fallback
  if (fallbackData !== undefined) {
    return {
      data: fallbackData as T,
      source: 'fallback',
      status: 0,
      retryCount: retries
    };
  }

  throw lastError;
}

function isRetryable(err: unknown): boolean {
  if (err instanceof DOMException && err.name === 'AbortError') return true;
  if (err instanceof TypeError) return true; // Network error
  if (err instanceof ApiError && err.status >= 500) return true;
  return false;
}

function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

class ApiError extends Error {
  constructor(public status: number, public body: string) {
    super(\`API error \${status}\`);
    this.name = 'ApiError';
  }
}

This client gives you a single function that handles every failure mode discussed in this guide. The source field in the response tells your UI whether it is showing live data, cached data, or a static fallback, so you can display an appropriate banner.

Integration with MSW (Mock Service Worker)

MSW is the industry standard for mocking API requests in both browser and Node environments. It intercepts requests at the network level, which means your application code — including all middleware, interceptors, and retry logic — runs exactly as it would in production.

// handlers.ts
import { http, HttpResponse, delay } from 'msw';

export const errorHandlers = [
  // Simulate a 500 error
  http.get('/api/users', async () => {
    return HttpResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }),

  // Simulate a timeout (request hangs for 30 seconds)
  http.get('/api/users', async () => {
    await delay(30000);
    return HttpResponse.json({ users: [] });
  }),

  // Simulate rate limiting with Retry-After header
  http.get('/api/users', async () => {
    return HttpResponse.json(
      { error: 'Too Many Requests' },
      {
        status: 429,
        headers: {
          'Retry-After': '5',
          'X-RateLimit-Remaining': '0'
        }
      }
    );
  }),

  // Simulate network error (connection refused)
  http.get('/api/users', () => {
    return HttpResponse.error();
  }),

  // Simulate intermittent failures (30% error rate)
  http.get('/api/users', async () => {
    if (Math.random() < 0.3) {
      return HttpResponse.json(
        { error: 'Server Error' },
        { status: 500 }
      );
    }
    await delay(200);
    return HttpResponse.json({
      users: [{ id: 1, name: 'Alice' }]
    });
  }),

  // Simulate slow response with realistic delay
  http.get('/api/users', async () => {
    await delay(8000); // 8 seconds
    return HttpResponse.json({
      users: [{ id: 1, name: 'Alice' }]
    });
  })
];

To use these in your tests, set up the MSW server and swap handlers per test case. This lets you test each error scenario in isolation without changing your application code. If you are setting up mock APIs for development, MSW handlers can reuse the same JSON fixtures.

// test setup
import { setupServer } from 'msw/node';
import { errorHandlers } from './handlers';

const server = setupServer();

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('shows error message on 500', async () => {
  server.use(errorHandlers[0]); // 500 handler
  render(<UserList />);
  expect(await screen.findByText(/something went wrong/i)).toBeVisible();
});

test('retries on timeout', async () => {
  let requestCount = 0;
  server.use(
    http.get('/api/users', async () => {
      requestCount++;
      if (requestCount < 3) {
        await delay(30000); // Timeout for first 2 attempts
        return HttpResponse.json({});
      }
      return HttpResponse.json({
        users: [{ id: 1, name: 'Alice' }]
      });
    })
  );
  render(<UserList />);
  expect(await screen.findByText('Alice')).toBeVisible();
  expect(requestCount).toBe(3);
});

Integration with Playwright

Playwright can intercept network requests at the browser level, making it ideal for end-to-end testing of error scenarios. You test the full user experience: loading states, error messages, retry buttons, and fallback UI.

import { test, expect } from '@playwright/test';

test('handles 500 error gracefully', async ({ page }) => {
  // Intercept API calls and return 500
  await page.route('**/api/users', route => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' })
    });
  });

  await page.goto('/dashboard');

  // Verify error UI is shown
  await expect(page.getByText('Unable to load users')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('handles timeout with retry', async ({ page }) => {
  let callCount = 0;

  await page.route('**/api/users', async route => {
    callCount++;
    if (callCount === 1) {
      // First request: simulate timeout by delaying 30s
      await new Promise(r => setTimeout(r, 30000));
      await route.abort('timedout');
    } else {
      // Subsequent requests: succeed
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ users: [{ id: 1, name: 'Alice' }] })
      });
    }
  });

  await page.goto('/dashboard');
  await expect(page.getByText('Alice')).toBeVisible({ timeout: 15000 });
});

test('handles network offline', async ({ page, context }) => {
  await page.goto('/dashboard');
  await expect(page.getByText('Alice')).toBeVisible();

  // Go offline
  await context.setOffline(true);

  // Trigger a refetch
  await page.getByRole('button', { name: 'Refresh' }).click();

  // Verify offline message
  await expect(page.getByText(/offline|no connection/i)).toBeVisible();

  // Come back online
  await context.setOffline(false);
  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByText('Alice')).toBeVisible();
});

Integration with Jest

For unit tests that do not need a browser, Jest's manual mocking works well. Mock the global fetch function to return exactly the failure you want to test.

// __tests__/api-client.test.ts

describe('API Client Error Handling', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
    jest.restoreAllMocks();
  });

  test('retries 3 times on 500 error', async () => {
    const mockFetch = jest.fn()
      .mockResolvedValueOnce(new Response('', { status: 500 }))
      .mockResolvedValueOnce(new Response('', { status: 500 }))
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ users: [] }), {
          status: 200,
          headers: { 'Content-Type': 'application/json' }
        })
      );

    global.fetch = mockFetch;

    const result = await resilientFetch('/api/users', {
      retries: 3,
      timeout: 5000
    });

    expect(mockFetch).toHaveBeenCalledTimes(3);
    expect(result.source).toBe('live');
    expect(result.retryCount).toBe(2);
  });

  test('falls back to cache on network error', async () => {
    localStorage.setItem('users-cache', JSON.stringify({
      data: { users: [{ id: 1, name: 'Cached Alice' }] },
      timestamp: Date.now()
    }));

    global.fetch = jest.fn()
      .mockRejectedValue(new TypeError('Failed to fetch'));

    const result = await resilientFetch('/api/users', {
      retries: 0,
      cacheKey: 'users-cache'
    });

    expect(result.source).toBe('cache');
    expect(result.data.users[0].name).toBe('Cached Alice');
  });

  test('respects Retry-After header on 429', async () => {
    const mockFetch = jest.fn()
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ error: 'Rate limited' }), {
          status: 429,
          headers: { 'Retry-After': '2' }
        })
      )
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ users: [] }), { status: 200 })
      );

    global.fetch = mockFetch;

    const fetchPromise = resilientFetch('/api/users');

    // Advance timers past the Retry-After period
    jest.advanceTimersByTime(2000);

    const result = await fetchPromise;
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });
});

Putting It All Together: A Testing Checklist

Before shipping any feature that makes API calls, verify these scenarios. Each one should be a test in your suite, or at minimum manually verified using the simulator above.

  1. Happy path — API returns 200 with expected data shape
  2. Empty state — API returns 200 with empty data ([], null)
  3. Timeout — Request hangs for longer than your timeout threshold
  4. 500 error — Server returns 500 with HTML body (not JSON)
  5. 503 during deploy — Server returns 503 with Retry-After header
  6. 429 rate limit — Server rejects request with rate limit headers
  7. Network offline — fetch() rejects with TypeError
  8. Slow response — Server responds after 10+ seconds
  9. Partial failure — One of multiple concurrent API calls fails
  10. Auth expired — Server returns 401, requiring token refresh

Use Kappafy to generate the mock data for your happy path, then use the simulator and code patterns in this guide to test everything that can go wrong. If you need to prettify JSON responses for debugging, Kappafy handles that too.

Frequently Asked Questions

How do I simulate a 500 error in a REST API?

Wrap your fetch() call with a function that randomly returns a Response object with status 500 and an error body. Set the probability to control how often the error occurs. In MSW, use HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 }) in your handler. The interactive simulator above generates the exact code for your endpoint.

How do I test API timeout handling in JavaScript?

Use AbortController with a setTimeout to create a timeout wrapper around fetch(). For testing, create a mock that returns a Promise that never resolves (or resolves after a very long delay), which triggers your timeout logic. In Jest, use jest.useFakeTimers() to advance time without actually waiting.

What is the difference between a timeout and a slow response?

A timeout occurs when the server never responds within the allowed window, causing the request to be aborted. Your catch block receives an AbortError. A slow response eventually completes but takes much longer than expected (e.g., 5-30 seconds). The request succeeds with status 200. Both need different handling: timeouts should trigger retries or fallbacks, while slow responses need loading states, progress indicators, and possibly user-initiated cancellation.

How do I simulate rate limiting (429) in tests?

Return a Response with status 429 and include Retry-After and X-RateLimit-Remaining headers. Your handler should track request counts and start returning 429 after a threshold. Verify that your code reads the Retry-After header and waits the specified duration before retrying. The most critical test: confirm that your retry loop does NOT send requests during the backoff period.

Can I use MSW to simulate network errors?

Yes. MSW supports HttpResponse.error() which simulates a network-level failure (like DNS resolution failure or connection refused). This triggers the fetch() catch block with a TypeError rather than returning an HTTP response, letting you test your network error handling paths. This is different from returning an HTTP error status — the response object does not exist at all.

Related: Mock APIs in 30 Seconds · Prettify JSON Online · Kappafy JSON Explorer

Related Tools