Error & Latency Simulation

This guide covers deliberately breaking your own mock layer: returning error statuses, delaying responses, timing requests out, truncating payloads and dropping connections, so the client’s failure handling is proven locally. It does not cover the mechanics of registering handlers in the first place — that belongs to request interception patterns — nor to load or performance testing, which measures your system rather than your error paths.

Prerequisites

  • Node.js 20+ with a working mock layer already intercepting traffic — either MSW 2.x or a WireMock standalone configuration
  • A test runner that supports fake timers (Vitest 1.x, Jest 29+, or equivalent)
  • Your HTTP client’s retry and timeout settings identified — you cannot assert on behaviour you have not configured
  • Real latency percentiles for the dependency you are simulating (p50/p95/p99 from production telemetry, or a documented SLA)
  • Familiarity with mock lifecycle management so fault state is reset between tests

Why the happy path is the least interesting path

A mock that always returns 200 OK in eight milliseconds trains your application to be optimistic. Every spinner, every retry, every “something went wrong” banner and every timeout guard is dead code until something exercises it. Those paths then get their first real workout in production, at the worst possible moment, against a dependency that has just started returning 503.

Fault injection closes that gap. The point is not to be pessimistic for its own sake — it is that the failure modes of a distributed system are a product surface. What the user sees when the payments API takes nine seconds is as much a design decision as the button that triggered it, and it deserves the same local iteration loop.

The five failure classes below cover nearly everything a browser or Node client will encounter in practice. Naming them explicitly matters: once each has a stable name, it can be selected by a flag, asserted in a test, and discussed in a review.

The five injectable fault classes Five labelled panels across one row: Status Error returns a 4xx or 5xx body, Slow Response delays before replying, Hard Timeout never replies at all, Malformed Body returns a truncated or wrong-shaped payload, and Connection Reset rejects the request before any response exists. A band beneath shows which application layer notices each one — the error branch, the loading state, the abort controller, the parser, and the network catch. Status Error 4xx / 5xx with a real error body fast, deterministic Slow Response correct payload, arrives late p95 / p99 shaped Hard Timeout no response at all within the budget client must abort Malformed Body 200 status, wrong or truncated shape parser / validator Connection Reset transport fails before any reply fetch rejects error branch catch / onError loading state spinner / skeleton abort guard AbortController schema guard zod / validator network catch offline banner Each fault class must land on a distinct, testable branch of the client — if two classes hit the same branch, one of them is untested.

Notice the asymmetry in the bottom band: a slow response and a hard timeout look identical from the mock’s side (both just delay), but they exercise completely different client code. The first must render a loading state and then succeed; the second must abandon the request and surface an error. Conflating them is the most common gap in a fault-injection setup.

Phase 1 — a fault vocabulary behind one switch

Start by making faults data, not code branches scattered across handlers. A single profile object maps a fault name to the behaviour, and one environment variable selects the active profile. This keeps the happy-path handlers untouched and makes the failure surface reviewable in one file.

// src/mocks/faults.ts
export type FaultName =
  | 'none'
  | 'server-error'
  | 'rate-limited'
  | 'slow'
  | 'timeout'
  | 'malformed'
  | 'reset';

export interface FaultProfile {
  /** Milliseconds to wait before responding. */
  delayMs: number;
  /** HTTP status to return, or null to return the normal payload. */
  status: number | null;
  /** Replace the body with a deliberately wrong shape. */
  corruptBody: boolean;
  /** Reject at the transport layer instead of responding. */
  resetConnection: boolean;
}

const PROFILES: Record<FaultName, FaultProfile> = {
  'none':         { delayMs: 0,      status: null, corruptBody: false, resetConnection: false },
  'server-error': { delayMs: 40,     status: 503,  corruptBody: false, resetConnection: false },
  'rate-limited': { delayMs: 40,     status: 429,  corruptBody: false, resetConnection: false },
  'slow':         { delayMs: 2600,   status: null, corruptBody: false, resetConnection: false },
  'timeout':      { delayMs: 120000, status: null, corruptBody: false, resetConnection: false },
  'malformed':    { delayMs: 40,     status: null, corruptBody: true,  resetConnection: false },
  'reset':        { delayMs: 20,     status: null, corruptBody: false, resetConnection: true },
};

function scale(ms: number): number {
  const factor = Number(process.env.MOCK_DELAY_FACTOR ?? '1');
  return Math.round(ms * (Number.isFinite(factor) ? factor : 1));
}

export function activeFault(): FaultName {
  const raw = (process.env.MOCK_FAULT ?? 'none') as FaultName;
  return raw in PROFILES ? raw : 'none';
}

export function profileFor(name: FaultName): FaultProfile {
  const p = PROFILES[name];
  return { ...p, delayMs: scale(p.delayMs) };
}

The MOCK_DELAY_FACTOR escape hatch matters more than it looks. It lets CI run the same slow profile at 0.05 — 130 ms instead of 2600 ms — while a developer running the profile locally still feels the real duration. Without it, teams inevitably fork the delay numbers between environments and the local experience stops matching what CI asserts.

Phase 2 — wiring the profile into the handlers

With the vocabulary defined, a single wrapper applies it to any resolver. This is the entire integration surface: handlers keep describing the happy path, and the wrapper decides whether that path is allowed to complete.

// src/mocks/withFaults.ts
import { HttpResponse, delay, type HttpResponseResolver } from 'msw';
import { activeFault, profileFor } from './faults';

export function withFaults(resolver: HttpResponseResolver): HttpResponseResolver {
  return async (info) => {
    const fault = profileFor(activeFault());

    if (fault.delayMs > 0) {
      await delay(fault.delayMs);
    }

    if (fault.resetConnection) {
      // Rejects the caller's fetch() with a TypeError, exactly as a dropped
      // connection or DNS failure does in a real browser.
      return HttpResponse.error();
    }

    if (fault.status !== null) {
      return HttpResponse.json(
        {
          error: fault.status === 429 ? 'rate_limited' : 'upstream_unavailable',
          message: 'Injected fault from the local mock layer.',
          retryable: fault.status >= 500 || fault.status === 429,
        },
        {
          status: fault.status,
          headers: fault.status === 429 ? { 'Retry-After': '2' } : undefined,
        }
      );
    }

    const response = await resolver(info);

    if (fault.corruptBody && response instanceof Response) {
      const text = await response.text();
      // Truncate mid-payload: a valid status with an unparseable body.
      return new HttpResponse(text.slice(0, Math.floor(text.length / 2)), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    return response;
  };
}

Handlers then opt in by wrapping, and nothing else about them changes:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { withFaults } from './withFaults';

export const handlers = [
  http.get(
    'https://api.example.com/orders/:id',
    withFaults(({ params }) =>
      HttpResponse.json({
        id: params.id,
        status: 'paid',
        total: { amount: 4250, currency: 'GBP' },
      })
    )
  ),
];

Two details are worth pausing on. First, the error body is a realistic error body — a code, a message and a retryable hint — not a bare status. Clients branch on payload fields at least as often as on status codes, so an empty error body under-tests the very branch you are trying to reach. Second, the 429 response carries Retry-After, because a rate-limit handler that ignores that header is a bug you want to find locally. The same principle drives dynamic response shaping: the shape of the response is the contract, in failure as much as in success.

How the fault wrapper routes a request A left-to-right decision flow. An incoming request enters the wrapper, which first applies the profile delay, then checks three conditions in order: reset connection produces a rejected fetch, a non-null status produces an error response, and a corrupt-body flag truncates the payload. If none match, the original happy-path resolver runs and returns the normal payload. Request GET /orders/:id await delay profile.delayMs × DELAY_FACTOR Ordered checks 1. resetConnection 2. status !== null 3. corruptBody HttpResponse.error() fetch rejects — TypeError reaches the caller 503 / 429 + error body retryable flag, Retry-After header 200 + truncated JSON parser or schema guard must catch it Original resolver runs happy-path payload, unchanged

The ordering in the middle box is deliberate and worth keeping: a connection reset must win over a status, because a transport failure means no HTTP response exists at all. Getting that order wrong produces the physically impossible combination of a 503 body delivered over a connection that was supposed to have dropped — and tests written against it will encode nonsense.

Phase 3 — driving faults from the outside

Environment variables are ideal for CI, where each job picks one profile. They are clumsy for interactive work, because changing one means restarting the dev server. For local exploration, add a runtime override driven by a request header so you can flip a fault from the browser console or a test without a restart.

// src/mocks/faults.ts (addition)
let runtimeOverride: FaultName | null = null;

export function setFault(name: FaultName | null): void {
  runtimeOverride = name;
}

export function activeFaultFor(request: Request): FaultName {
  const header = request.headers.get('x-mock-fault') as FaultName | null;
  if (header && header in PROFILES) return header;
  if (runtimeOverride) return runtimeOverride;
  const raw = (process.env.MOCK_FAULT ?? 'none') as FaultName;
  return raw in PROFILES ? raw : 'none';
}

The precedence — per-request header, then process-wide override, then environment default — means a single test can demand a fault without disturbing its neighbours, which matters enormously once specs run in parallel. Switch withFaults to call activeFaultFor(info.request) and the whole stack gains per-request control for free.

On the WireMock side the same three levers exist natively, expressed as mapping properties rather than code:

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/orders/[^/]+",
    "headers": { "X-Mock-Fault": { "equalTo": "server-error" } }
  },
  "response": {
    "status": 503,
    "fixedDelayMilliseconds": 40,
    "jsonBody": {
      "error": "upstream_unavailable",
      "message": "Injected fault from the local mock layer.",
      "retryable": true
    },
    "headers": { "Content-Type": "application/json" }
  },
  "priority": 1
}

Setting priority to 1 puts the fault mapping ahead of the default stub, so the header alone flips behaviour without unloading anything. WireMock also exposes fault for transport-level chaos — CONNECTION_RESET_BY_PEER, EMPTY_RESPONSE and MALFORMED_RESPONSE_CHUNK — which reach below HTTP in a way an in-process interceptor cannot. That is the practical dividing line described in proxy vs inline mocking strategies: anything that must be wrong beneath fetch needs a real socket on the other end.

For randomised latency rather than a fixed value, WireMock’s log-normal distribution is closer to real dependency behaviour than any constant:

{
  "response": {
    "status": 200,
    "delayDistribution": {
      "type": "lognormal",
      "median": 180,
      "sigma": 0.4
    }
  }
}

A log-normal curve produces a dense cluster near the median with a long right tail — the shape almost every real service exhibits. A fixed 180 ms delay, by contrast, never produces the occasional 900 ms outlier that actually breaks a naive client.

Fixed delay versus a log-normal latency distribution Two histograms sharing a latency axis from 0 to 900 milliseconds. The fixed-delay chart is a single tall bar at 180 milliseconds and nothing elsewhere. The log-normal chart clusters most responses between 120 and 260 milliseconds but keeps a thinning tail out past 700 milliseconds, which is where a client with no timeout guard fails. Fixed delay: 180 ms Log-normal: median 180 ms, sigma 0.4 180 0 900 ms Every response identical — the tail is never exercised. A client with no timeout guard passes. p99 180 0 900 ms A dense body plus a thinning tail past p99. The same client now times out on roughly one call in a hundred.

The dashed p99 marker is where the interesting behaviour lives. Under the fixed profile no request ever crosses it, so a missing timeout guard is invisible; under the log-normal profile roughly one call in a hundred does, which is frequent enough for a test run to catch and rare enough to match what production actually looks like.

Verification steps

Run each profile as its own job and assert on the observable recovery rather than on the mock’s internals. The assertions below are the minimum set worth having.

  • MOCK_FAULT=none npx vitest run — the baseline suite is green with no artificial failures
  • MOCK_FAULT=server-error npx vitest run src/features/orders — the error surface renders and no unhandled rejection is logged
  • MOCK_FAULT=slow MOCK_DELAY_FACTOR=0.05 npx vitest run — the loading state appears and is then replaced by real content
  • MOCK_FAULT=timeout npx vitest run — the request is aborted at the configured ceiling, not left hanging until the runner’s own timeout
  • MOCK_FAULT=malformed npx vitest run — the schema guard rejects the payload and the UI shows an error rather than a half-rendered view
  • MOCK_FAULT=reset npx vitest run — the network catch fires and the offline or connectivity banner appears

A concrete example of the timeout assertion, which is the one teams most often get wrong:

// src/features/orders/orders.timeout.test.ts
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { fetchOrder } from './api';

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it('aborts at the configured ceiling rather than hanging', async () => {
  const pending = fetchOrder('ord_1');            // MOCK_FAULT=timeout
  const assertion = expect(pending).rejects.toThrowError(/aborted/i);

  await vi.advanceTimersByTimeAsync(8_000);       // the client's own ceiling
  await assertion;
});

The advanceTimersByTimeAsync call is what keeps the suite fast: the mock’s 120-second delay never actually elapses in wall-clock time, but the client’s abort timer fires exactly as it would in production. Without fake timers this single spec would add two minutes to every run, and someone would delete it.

Troubleshooting

Error: Cannot find module 'msw/node' after adding the fault wrapper. The wrapper imports delay from msw, which is correct, but a stale MSW 1.x install exposes it from msw/lib/core instead. Check with npm ls msw and upgrade to 2.x; the 1.x resolver signature (req, res, ctx) is incompatible with the wrapper shown here.

The slow profile has no effect in the browser. The Service Worker file is almost certainly stale. mockServiceWorker.js is generated once by npx msw init and does not update itself when MSW is upgraded. Re-run the init command, hard-reload, and confirm in DevTools → Application → Service Workers that the active worker’s version matches your installed MSW.

Every spec fails with AbortError once faults are enabled. The process-wide MOCK_FAULT leaked into specs that expected the happy path. Reset it between files with afterEach(() => setFault(null)) in the shared setup, and prefer the per-request x-mock-fault header for tests that need a fault — process-wide state and parallel test files are a bad combination, as covered in mock lifecycle management.

HttpResponse.error() produces a generic TypeError with no detail. That is correct and intentional — the browser deliberately hides the cause of a network failure from page JavaScript for security reasons. If a test needs to distinguish DNS failure from connection reset, that distinction does not exist at the fetch layer; assert on your own error classification instead.

WireMock returns 200 even with the fault mapping loaded. Mapping priority is the usual cause: the default stub was registered with an equal or lower priority number and wins ties by insertion order. Give the fault mapping "priority": 1 and the default "priority": 5, then confirm with curl -s http://localhost:8080/__admin/mappings | jq '[.mappings[] | {priority, url: .request.urlPathPattern}]'.

When to advance

You have this in place when a new engineer can run one command to see the application’s behaviour under any named failure, when each fault class has at least one assertion that would fail if the corresponding client guard were deleted, and when the CI matrix runs the fault profiles alongside the clean profile rather than instead of it. At that point the next step is to make the failures sequenced rather than constant — a dependency that fails twice and then succeeds is the shape most retry logic actually has to survive, which is where stateful scenario sequences take over.


FAQ

Should fault injection be on by default in local development?

No. Keep the default profile clean so day-to-day feature work is not slowed by artificial failures, and expose faults behind an explicit environment variable or admin header. Ambient chaos in the dev loop trains people to ignore errors — the opposite of the goal. Run the fault profiles as their own CI job so the coverage is deliberate, reproducible, and visible as a separate signal when it breaks.

What latency numbers should I simulate?

Use the real percentiles from your production telemetry rather than round numbers. A median near p50 keeps the everyday experience honest; a slow profile at p99 plus a hard timeout at the client’s configured ceiling exposes the loading and abort paths. Simulating a flat 2000 ms teaches you very little, because no real dependency behaves that way — the interesting failures come from the tail, not the mean. Where you have no telemetry, a log-normal distribution with a realistic median is still far better than a constant.

How do I stop fake latency from making the whole test suite slow?

Use fake timers in unit tests so the delay is advanced instantly, and reserve real wall-clock delays for a small number of integration specs where the timing itself is the subject. Where the runner cannot fake timers, scale the delay through a multiplier environment variable — MOCK_DELAY_FACTOR=0.05 in CI — so the same scenarios run at a fraction of the local duration without maintaining two sets of numbers.

Can a Service Worker mock simulate a connection reset?

Yes, in the sense the client observes. Returning HttpResponse.error() from an MSW resolver rejects the fetch with a TypeError, which is exactly what a DNS failure or dropped connection produces in the browser. What it cannot reproduce is behaviour below fetch — a half-open socket, a TLS handshake failure, or a response that is truncated mid-chunk at the transport layer. Those need a real server on the other end, which is what WireMock’s fault property provides.


← Back to API Mocking Fundamentals & Architecture