Mocking API Responses for Frontend Work: Fixtures, MSW and Oversized Payloads
Frontend work stalls when it waits for the backend. Mocking removes the wait, but only if the mocks stay close to what the real API will do. Mocks that only ever return the happy path produce a UI that looks finished and falls over the first time the API is slow, empty or wrong. This post shows the setup we use: fixtures that are checked against a schema, request handlers that cover every state, and a few deliberately awkward payloads.
1. Agree on the contract, then write fixtures from it
Start with a schema both sides accept. OpenAPI is the most common choice; a plain JSON Schema file works too. Keep example responses as JSON files next to the schema:
mocks/
schema/user.schema.json
fixtures/
user.ok.json
user.minimal.json # only required fields
users.empty.json # { "items": [], "total": 0 }
users.page-1.json
error.500.json
The minimal fixture is the one people forget. Real API responses often omit optional fields, and a UI built only against full fixtures crashes on user.address.city when address is missing.
2. Validate fixtures in CI so they cannot drift
Mocks rot quietly. The backend adds a required field, the fixtures do not change, and the frontend keeps passing tests against a shape that no longer exists. A short check with Ajv stops that:
import Ajv from 'ajv';
import { readFileSync, readdirSync } from 'node:fs';
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(JSON.parse(readFileSync('mocks/schema/user.schema.json', 'utf8')));
for (const f of readdirSync('mocks/fixtures').filter((n) => n.startsWith('user.'))) {
const data = JSON.parse(readFileSync('mocks/fixtures/' + f, 'utf8'));
if (!validate(data)) {
console.error(f, validate.errors);
process.exitCode = 1;
}
}
When the schema changes, this fails until the fixtures are updated, which is exactly when you want to find out.
3. Serve the fixtures with Mock Service Worker
Mock Service Worker (MSW) intercepts fetch and XHR in the browser (via a service worker) and in Node (for unit tests), so your application code calls the real URLs and does not know it is mocked. With MSW 2.x:
import { http, HttpResponse, delay } from 'msw';
import ok from './fixtures/user.ok.json';
import empty from './fixtures/users.empty.json';
export const handlers = [
http.get('/api/user/:id', async () => {
await delay(300); // realistic latency, not zero
return HttpResponse.json(ok);
}),
http.get('/api/users', ({ request }) => {
const q = new URL(request.url).searchParams.get('q');
return q === 'nobody' ? HttpResponse.json(empty) : HttpResponse.json(ok);
}),
];
The delay(300) matters. With zero latency, loading states flash for one frame and nobody notices they are broken.
4. Make every UI state reachable on purpose
We add a small switch so a developer or tester can force a state without editing code, for example by a query parameter read inside the handler:
- Slow:
await delay(8000). Is there a spinner? Can the user navigate away without an error? - Never resolves:
await delay('infinite'). Does the UI time out on its own? - Empty: an empty list with
total: 0. Is there an empty state, or a blank area? - Server error:
HttpResponse.json(err, { status: 500 }). Is the message readable, and is there a retry? - Network failure:
HttpResponse.error(). This is different from a 500: the request never got a response, and many apps handle only one of the two. - Unauthorized: status 401. Does the app redirect to login and come back to the same page?
5. Include an oversized payload
Most mock data is small, so the UI never meets a large response until production. We keep at least one fixture that is deliberately too big: a list endpoint returning 10,000 items, or a single response padded to several hundred KB. It reveals missing pagination, lists rendered without virtualization, and components that re-render the whole list on every keystroke.
If you only need the payload size (for example to test a client-side size guard or a gateway limit), a padded JSON file from /generator?ext=json&bytes=750000 is enough: it parses to a small object but is 750,000 bytes on the wire. If you need parse and render cost, generate a large array of real objects instead. The JSON payload bloat scenario covers both.
6. Mock the upload endpoint too
Upload flows need their own mocks because the interesting behaviour is in the errors. This handler enforces a limit the way a real server would:
const MAX = 5 * 1024 * 1024; // 5,242,880 bytes
http.post('/api/upload', async ({ request }) => {
const form = await request.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return HttpResponse.json({ code: 'NO_FILE' }, { status: 400 });
}
if (file.size > MAX) {
return HttpResponse.json({ code: 'FILE_TOO_LARGE', max: MAX }, { status: 413 });
}
await delay(1500);
return HttpResponse.json({ id: 'f_123', size: file.size }, { status: 201 });
});
Pair it with files at 5,242,880 and 5,242,881 bytes (the 5 MB PDF preset is a quick start) and you can build the whole error UI before the real endpoint exists. Note that a mock receives the whole body at once, so it cannot show real upload progress; test progress bars against a real server with throttling.
7. Use route mocking in end-to-end tests
In Playwright, you can stub a single endpoint for one test without a service worker:
test('shows empty state', async ({ page }) => {
await page.route('**/api/users*', (route) =>
route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ items: [], total: 0 }) })
);
await page.goto('/users');
await expect(page.getByText('No users yet')).toBeVisible();
});
Cypress has the equivalent in cy.intercept('GET', '/api/users*', { fixture: 'users.empty.json' }).
8. Switch mocks off deliberately
Enable MSW only in development and tests (for example behind import.meta.env.DEV in Vite), and run at least one test suite against the real staging API before release. Mocks let you build in parallel; they do not prove the integration works.
Checklist
- A schema both teams agree on.
- Full, minimal, empty and error fixtures for each endpoint.
- Fixtures validated against the schema in CI.
- Handlers with realistic latency and a way to force each state.
- At least one oversized response and a size-limited upload mock.
- A real-API run before release.
