MSW vs json-server for Prototyping
Two tools, both reasonable, aimed at different moments. json-server turns a JSON file into a full CRUD API with no code; MSW intercepts in-process and grows into the test suite. This page compares them on the dimensions that decide an early-stage project, and describes the migration when the first choice stops fitting.
Context: a running server versus an interceptor
json-server is a real HTTP server on a port. It reads a JSON file, derives REST routes from its top-level keys, and implements the whole verb set — including filtering, sorting, pagination and relationship expansion — without a line of code. State is genuinely mutable: a POST adds a record that a later GET returns, and by default it writes back to the file.
MSW is not a server. It patches the transports inside your process, so there is nothing to start, nothing to wait for and no port. In exchange, every behaviour is code you write — including the CRUD semantics json-server gives away.
The trade-off is between how fast you get moving and how much of the work survives. Neither answer is universally right, and the failure mode is choosing without noticing there was a choice.
Solution
1. Reach for json-server while the shape is moving
{
"orders": [
{ "id": "ord_0001", "customerId": "cus_a1", "status": "paid", "totalMinor": 4250 },
{ "id": "ord_0002", "customerId": "cus_a1", "status": "pending", "totalMinor": 1899 }
],
"customers": [
{ "id": "cus_a1", "name": "K. Osei", "email": "[email protected]" }
]
}
npx json-server --watch db.json --port 4000 --delay 180
That single command gives you GET /orders, GET /orders/ord_0001, POST /orders, PATCH, DELETE, plus GET /orders?status=paid, ?_sort=totalMinor, ?_page=2&_limit=20 and GET /customers/cus_a1?_embed=orders. Reproducing that surface in handlers is a genuine afternoon.
--delay 180 is worth setting from the start: a zero-latency prototype hides every loading state, for the reasons set out in simulating network latency in MSW.
2. Reach for MSW when the work must survive
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import db from '../../db.json'; // the SAME file json-server used
export const handlers = [
http.get('https://api.example.com/orders', ({ request }) => {
const status = new URL(request.url).searchParams.get('status');
const items = status ? db.orders.filter((o) => o.status === status) : db.orders;
return HttpResponse.json({ items, total: items.length });
}),
http.get('https://api.example.com/orders/:id', ({ params }) => {
const found = db.orders.find((o) => o.id === params.id);
return found
? HttpResponse.json(found)
: HttpResponse.json({ error: 'not_found', retryable: false }, { status: 404 });
}),
];
Importing the same db.json is the detail that makes the migration cheap. The dataset stops being json-server’s private concern and becomes a fixture both tools read, so switching is a rewiring rather than a rewrite.
3. Know why json-server struggles in a test suite
Three properties that are virtues in a prototype become problems under a runner.
It is a separate process, so every test job must start it, wait for it and tear it down — the lifecycle work that in-process interception avoids entirely.
It is globally stateful, so four parallel workers share one dataset. A spec that creates an order changes what a concurrent spec sees, and the failures appear only under parallelism.
It persists to disk by default, so a test run mutates the file in your working tree. The next run starts from different data and git status shows a change nobody made deliberately.
Mitigating all three is possible — one instance per worker on a dynamic port, --no-save, a reset between specs — and at that point you have rebuilt, worse, what MSW gives by default.
4. Plan the switch
The migration is cheap if the dataset was never trapped inside json-server:
# Freeze the current state as a fixture the handlers import
curl -s http://localhost:4000/db > src/mocks/generated/db.json
# From here, json-server is optional — the handlers own the contract
npx json-server --watch src/mocks/generated/db.json --port 4000 --no-save
--no-save from that point on stops the server mutating the committed fixture, so the two tools read the same file and only your editor writes to it.
Verification
# json-server derives the routes you expect
curl -s 'http://localhost:4000/orders?status=paid&_limit=5' | jq 'length'
curl -s 'http://localhost:4000/customers/cus_a1?_embed=orders' | jq '.orders | length'
# The MSW handlers answer the same shapes from the same file
npx vitest run src/mocks/handlers.contract.test.ts
Keeping a small contract spec that asserts both tools return the same shape for the same query is what stops them drifting during the period both are in use.
Gotchas and edge cases
-
json-server writes to your working tree by default. A prototype session mutates
db.json, and the change is committed by accident with the next feature. Pass--no-saveunless persistence is the point of the session. -
Its route derivation only handles flat collections. Nested resources such as
/customers/:id/orders/:orderIdare not derived from the file and need a custom routes mapping. When your API shape stops being flat, most of what json-server gives you for free stops applying. -
Neither tool tells you the API is real. Both are stand-ins for a contract that may not exist yet, and a prototype built against an invented shape can be a long way from what the backend eventually ships. Generate from a specification as soon as one exists — see generating mock data from an OpenAPI spec.
What the prototype phase should actually produce
The point of choosing a tool for the prototype phase is not to move quickly for its own sake. It is to end that phase with something worth keeping, and the two tools differ mainly in what they leave behind.
A json-server prototype leaves a dataset. The db.json file that drove the prototype is a genuine artefact: it encodes the entities, their fields and their relationships as the team understood them at the end of the exploration. That file becomes the seed for whatever comes next, and importing it into handlers is a few lines.
An MSW prototype leaves a handler set. That is more valuable if the shape settled early, because the handlers themselves carry forward into the test suite. It is less valuable if the shape churned, because handlers written against three superseded shapes are mostly deletions.
The decision therefore turns on a question that is answerable at the start: how settled is the API shape? If a specification exists, or the backend is already built, the shape is settled and handlers written now will survive. If the exploration is partly about discovering what the endpoints should be, a JSON file is the cheaper place to iterate and the dataset is the thing worth keeping.
There is a third outcome worth avoiding: a prototype that leaves neither. That happens when the mock lives only in a running process, configured by hand through an admin API or a browser extension, with nothing committed. The demo works, the exploration concludes, and the next person starts from nothing.
Whichever tool is used, the discipline that makes the phase pay is the same: whatever the mock returns must exist as a committed file at the end of it. That single requirement is what turns a prototype into a starting point rather than a detour.
Related
- MSW vs WireMock for CI Pipelines — the same comparison one stage later in a project’s life
- Choosing Between Proxy and Service Worker Mocks — the architectural version of this decision
- Mocking Tool Selection — the full evaluation workflow
← Back to Mocking Tool Selection