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://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)"}
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.
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`)
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.
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.
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.
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.
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.