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.

Effort over the life of a project Two effort curves over four project stages: first sketch, shaping the API, writing the test suite, and long-term maintenance. The json-server curve starts near zero and rises sharply at the test-suite stage, where per-worker instances and resets become necessary. The MSW curve starts higher because handlers must be written, then stays flat because the same definitions serve the tests. effort first sketch shaping the API writing the tests maintenance json-server MSW json-server starts at almost nothing MSW costs handlers up front The curves cross when the test suite arrives — which is the point most prototypes reach faster than anyone expects.

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.

Six dimensions, two answers A comparison table across six dimensions. json-server wins on time to first response, free CRUD semantics and cross-language reach. MSW wins on test-suite fit, parallel isolation and reuse of the same definitions across environments. Statefulness is listed as a virtue for prototyping and a hazard for testing. Dimension json-server MSW time to first response one command install, init worker, write handlers CRUD, filter, sort, paginate free code you write reachable by non-JS clients yes — it is a server no — JavaScript runtimes only parallel test isolation shared state across workers in-process, per worker error and latency injection a global delay flag per request, per test definitions reused in tests the dataset only the whole handler set

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-save unless persistence is the point of the session.

  • Its route derivation only handles flat collections. Nested resources such as /customers/:id/orders/:orderId are 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.


Migrating off the prototype Five migration steps in order: freeze the dataset as a committed fixture, stop the server writing back to it, import the same file from the handlers, move one endpoint at a time, and finally retire the server. Each step keeps both tools working, so the migration is never all-or-nothing. Step Action Both tools still work? 1 freeze the dataset as a fixture yes 2 pass --no-save yes 3 import the same file from handlers yes 4 move endpoints one at a time yes 5 retire the server no longer needed Because every step preserves both, the migration can pause indefinitely without leaving anything broken.

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.

← Back to Mocking Tool Selection