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.
| Param | What it does |
|---|---|
?mock_delay=2000 | Holds the response for N ms (max 5000) before answering normally. |
?mock_status=500 | Returns 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://HOST/m/demo/products?mock_delay=3000" # answers after 3s
curl -i "https://HOST/m/demo/products?mock_status=503" # โ 503 {"error":"simulated 503 error (mock_status)"}
Point one fetch at a delayed URL and watch your UI honestly render its loading state:
const API = "https://HOST/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.
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?
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.
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?
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`)
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.
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.
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.
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.
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.
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.