โ† All guides

Test loading & error states without touching your code

Every app has a happy path, a loading state, and an error state. You built all three โ€” but you've only ever seen one of them, because your dev API answers in 40 ms and never fails. Then production ships, a request takes four seconds on hotel wifi, and your "loading state" turns out to be a blank white screen.

The usual workarounds are all bad: dev-tools network throttling slows down everything including your hot reload; conditional if (FAKE_ERROR) throw code sneaks into commits; mock service workers need per-test setup. Mockbird bakes failure into the API itself: two query params, on every endpoint, no configuration.

ParamWhat it does
?mock_delay=2000Holds the response for N ms (max 5000) before answering normally.
?mock_status=500Returns that HTTP status (400โ€“599) with a JSON error body instead of data.

Try them right now against the public demo โ€” no signup, no setup:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_delay=3000"   # answers after 3s
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=503" # โ†’ 503 {"error":"simulated 503 error (mock_status)"}

1. Does your skeleton loader actually show?

Point one fetch at a delayed URL and watch your UI honestly render its loading state:

const API = "https://mockbird.mockbird.workers.dev/m/demo";

// during development, add the flag:
fetch(`${API}/products?mock_delay=2500`)
  .then(r => r.json())
  .then(render);

Things you'll catch in the first minute: layout shift when the data pops in, spinners that never appear because a cache short-circuits them, skeletons sized for 3 rows when the response has 30. Because the delay happens server-side, everything else โ€” your bundler, your other requests โ€” stays fast.

2. Race conditions, the classic one

Type-ahead search has a famous bug: the response for "an" arrives after the response for "ana" and overwrites it. You can't reproduce that against a fast local API. You can with a delay flag:

// fire a slow request, then a fast one
fetch(`${API}/products?search=an&mock_delay=3000`).then(r => r.json()).then(show);
fetch(`${API}/products?search=ana`).then(r => r.json()).then(show);
// wrong-order arrival guaranteed. Does your AbortController / stale-check cope?

3. Does your error boundary catch it?

Every status from 400 to 599 is available. The ones worth wiring into a checklist:

fetch(`${API}/products?mock_status=500`)  // generic failure โ†’ error boundary / toast?
fetch(`${API}/products?mock_status=401`)  // expired session โ†’ redirect to login?
fetch(`${API}/products?mock_status=403`)  // forbidden โ†’ useful message, not a crash?
fetch(`${API}/products?mock_status=404`)  // gone โ†’ empty state, not spinner-forever?
fetch(`${API}/products?mock_status=429`)  // rate limited โ†’ back off and retry?

The error body is JSON ({"error":"simulated 500 error (mock_status)"}) with CORS headers intact, so your response-parsing code runs exactly as it would on a real failure โ€” including the branch where you try to .json() an error response.

4. Slow and failing

Real outages are rarely instant. Combine the flags to simulate a timeout-then-error, the case that breaks naive retry loops:

fetch(`${API}/products?mock_delay=4000&mock_status=502`)
// 4 seconds of suspense, then a 502. Does retry #2 also wait politely?

4ยฝ. Chaos mode: test retry & backoff for real

mock_status fails every request โ€” great for error boundaries, useless for retry logic (a retry against a always-500 endpoint never succeeds). mock_chaos fails a random fraction:

// 30% of requests randomly return 500/502/503/504/429 โ€” the rest succeed
fetch(`${API}/products?mock_chaos=0.3`)

// pick your poison: only 503s + 429s (test your Retry-After handling)
fetch(`${API}/products?mock_chaos=0.5&mock_chaos_status=503,429`)

// realistic network: 100โ€“1500ms random latency on top
fetch(`${API}/products?mock_chaos=0.2&mock_jitter=100-1500`)

Rate limits deserve their own drill. Chaos 429s are random; a real API bans you deterministically once you burn the quota. ?mock_ratelimit=5 allows exactly 5 requests per 60-second window from your IP, then returns 429 with a live Retry-After โ€” and every response (success included) carries x-ratelimit-limit/x-ratelimit-remaining/x-ratelimit-reset headers, so you can test the countdown UI and the "slow down before you hit the wall" logic, not just the crash:

# burn the window, watch remaining drop, then read Retry-After
for i in $(seq 1 7); do curl -si "$API/products?mock_ratelimit=5" | grep -i -E '^(HTTP|x-ratelimit-remaining|retry-after)'; done

Injected failures carry an x-mockbird-chaos: injected response header, so your test can count how many were chaos vs. real. Two guarantees that make this safe to point retry logic at: a chaos-failed write is never applied (like a server that died before processing โ€” so you can verify re-sending is safe), and with mock_chaos under 1 a retry loop with enough attempts always converges. A quick Playwright example:

test('retries survive a flaky backend', async ({ page }) => {
  await page.route('**/api/**', route => {
    const u = new URL(route.request().url());
    u.searchParams.set('mock_chaos', '0.4');       // 40% failure rate
    route.continue({ url: u.toString() });
  });
  await page.goto('/dashboard');
  await expect(page.getByRole('table')).toBeVisible();  // still renders โ€” retries did their job
});

Hosted chaos engineering like this is an enterprise-tier feature at WireMock Cloud. Here it's a query param.

4โ…. Deterministic sequences: fail exactly twice, then succeed

Chaos is probabilistic โ€” great for soak-style resilience checks, flaky for assertions. When a test needs to know the first two calls fail and the third succeeds, use mock_seq: a comma-separated list of statuses served in order, one per request, sticking on the last entry once exhausted. This is WireMock's "scenario" state machine, as a query param:

# 1st request โ†’ 503, 2nd โ†’ 503, 3rd and later โ†’ the real product list
curl -si "$API/products?mock_seq=503,503,200" | head -1   # HTTP/2 503  (x-mockbird-seq: 1/3)
curl -si "$API/products?mock_seq=503,503,200" | head -1   # HTTP/2 503  (x-mockbird-seq: 2/3)
curl -si "$API/products?mock_seq=503,503,200" | head -1   # HTTP/2 200  (x-mockbird-seq: 3/3)

# restart the sequence in a beforeEach (that request counts as #1 again)
curl -si "$API/products?mock_seq=503,503,200&mock_seq_reset=1" | head -1   # HTTP/2 503

Now the retry assertion is exact: expect exactly 3 attempts, and the response to be real data โ€” no mock_chaos probability tuning, no retry-count fuzz. The counter is keyed per project + path + sequence, so parallel test workers hitting the same URL share it; give each worker its own counter with &mock_seq_key=${workerId}. Entries โ‰ฅ400 return a simulated error (and a failed write is never applied, same guarantee as chaos โ€” replaying it is safe); entries <400 serve the real response. Every response carries x-mockbird-seq: pos/len so you can assert where in the story you are.

4ยพ. Streaming UIs: skeletons that fill in row by row

Progressive rendering is its own class of loading state โ€” and it's hard to test because most mock tools return one JSON blob. Any Mockbird list can stream instead:

# Server-Sent Events, one product every 300 ms, then a `done` event
curl -N 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_sse=1&limit=5&mock_stream_interval=300'

# NDJSON for fetch-stream readers
curl -N 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_stream=1&limit=5'
const es = new EventSource(`${API}/products?mock_sse=1&limit=10&mock_stream_interval=300`);
es.addEventListener("products", e => appendRow(JSON.parse(e.data)));
es.addEventListener("done", () => es.close());

Watch your list populate one row at a time, verify the spinner hands off to real content without layout shift, and test EventSource.onerror by adding &mock_status=503. Full streaming walkthrough (NDJSON readers, reconnect testing, auth-protected streams): the SSE mocking guide.

5. Use it in end-to-end tests

Because the failure lives in the URL, E2E tests don't need any interception setup. One Playwright example:

test("shows error toast on server failure", async ({ page }) => {
  await page.route("**/products*", route =>
    route.continue({ url: route.request().url() + "&mock_status=500" }));
  await page.goto("/catalog");
  await expect(page.getByText("Something went wrong")).toBeVisible();
});

One line rewrites the request to its failing twin โ€” the app code under test is byte-identical to production. The same trick works in Cypress with cy.intercept. (Composing this with other routes? Use route.fallback, not continue โ€” the false-positive it prevents is dissected in the Playwright guide.)

The rule that keeps this from becoming a hidden dependency: simulation params never appear in application source. They're injected at the test boundary โ€” the runner's network layer (above), a QA engineer's address bar, or a test-scoped header: test.use({ extraHTTPHeaders: { "X-Mockbird-Snapshot": "edge-cases" } }) pins every request in a spec file to a saved scenario without touching a single URL. Your app knows only its base URL from an env var; grep mock_ src/ should come back empty.

6. Hand it to QA

The best part: these flags need zero technical setup, so anyone can use them. Paste a URL with ?mock_status=500 into a bug report and the repro is the link itself. QA can walk the whole error matrix with nothing but a browser address bar.

7. Check what your app actually sent

When a test fails and you're not sure whether the request even went out (or went out with the wrong body), open the request inspector in the dashboard: the last 50 requests per project are logged with method, path, query string, response status, Origin, and the body of writes. Tick "live" and watch requests stream in while you click through your app โ€” no proxy, no dev-tools archaeology.

8. Reset the data itself between tests

Latency and status flags make failures deterministic; snapshots make the data deterministic too. Save the project state as baseline once, then a single POST โ€ฆ/snapshots/baseline/restore in your beforeEach puts every record back exactly as saved โ€” ids included. Full Playwright/Cypress setup: deterministic test data guide.

9. Bonus: test the events, not just the requests

If your app also consumes webhooks, the same project can generate them: configure a webhook URL and Mockbird fires an HMAC-signed POST every time a record is created, updated or deleted โ€” so you can rehearse your consumer against real, signed deliveries before the production event source exists. Full walkthrough: send test webhooks to any URL.

The examples above use the shared demo project, which resets daily. For your own data, create a project (no signup needed) on the landing page โ€” the flags work on every endpoint, including nested routes and single records. Docs โ†’
โšก Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard โ€” real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.