Hot-Reloading Mock Definitions Without Restart

You change a stub, restart the mock server, wait for the JVM, re-navigate, re-authenticate, and get back to the screen you were looking at — thirty seconds to see a one-character edit. This page removes that loop for both WireMock standalone and MSW, and explains why the same mechanism must be switched off in CI.

Context: why a restart is the default

Mock servers load their definitions once at startup because that is the safe behaviour. A stub set is a whole: mappings can overlap, priorities are relative, and a scenario’s states only make sense together. Reading them once means every request is served by a consistent set.

Reloading breaks that guarantee unless it is done carefully. Naive file watching fires per file, so a five-file edit produces five reloads and four windows where the set is inconsistent. Editors make it worse — many write a temporary file and rename it, which surfaces as a delete followed by a create, and a watcher that reloads on delete will briefly serve an empty stub set.

The fix is not to watch harder but to debounce and swap atomically: collect changes for a moment, build the complete new set, and replace the old one in a single assignment.

Per-file reload versus a debounced atomic swap Two timelines. The naive version fires a reload after each of four file events, leaving three intervals in which the stub set is partially applied and any request arriving is served inconsistently. The debounced version collects the same four events, waits for quiet, builds the complete set and swaps it in one step, so no request ever sees a partial set. Naive: reload per file event partial set partial set partial set write a.json write b.json rename c.json a request landing in any shaded band gets an inconsistent answer Debounced: one atomic swap quiet period — old set still serving, fully consistent swap new set serving, fully consistent The old set stays authoritative until the new one is completely built, so there is no window in which a partial set can answer a request. A 150 ms debounce absorbs editor write-then-rename sequences and multi-file saves alike.

Solution

1. WireMock — watch, then reload through the admin API

WireMock’s admin API can replace the whole mapping set in one call, which gives the atomic swap for free:

#!/usr/bin/env bash
# scripts/watch-mappings.sh — reload WireMock stubs on edit (development only)
set -euo pipefail

ADMIN="${WIREMOCK_ADMIN:-http://localhost:8080/__admin}"
DIR="${MAPPINGS_DIR:-./wiremock/mappings}"

reload() {
  # /mappings/reset re-reads the mounted mappings directory in one operation.
  if curl -sf -X POST "$ADMIN/mappings/reset" >/dev/null; then
    printf '%s  reloaded %d mapping(s)\n' \
      "$(date +%T)" "$(curl -sf "$ADMIN/mappings" | jq '.mappings | length')"
  else
    printf '%s  reload FAILED — is WireMock running at %s?\n' "$(date +%T)" "$ADMIN" >&2
  fi
}

reload
# -t debounces: inotifywait emits one line after the burst settles.
while inotifywait -q -r -e close_write,move,create,delete "$DIR" >/dev/null; do
  sleep 0.15          # absorb editor write-then-rename bursts
  reload
done

/mappings/reset re-reads the mounted directory and replaces the in-memory set; it does not touch the request journal, so anything you were inspecting survives the reload. That distinction matters — the broader /__admin/reset clears mappings, scenarios and the journal together, which is almost never what you want mid-session.

For the mappings to be re-readable at all, the directory has to be mounted rather than baked into the image:

# docker-compose.dev.yml
services:
  wiremock:
    image: wiremock/wiremock:3.13.2
    command: ["--global-response-templating", "--verbose"]
    ports: ["8080:8080"]
    volumes:
      # Read-write in development so the reload sees your edits immediately.
      - ./wiremock/mappings:/home/wiremock/mappings
      - ./wiremock/__files:/home/wiremock/__files

2. MSW — replace the handler array in place

MSW captures the handler array when the worker starts, so editing the module changes nothing on its own. The bundler’s hot-update hook is where you hand the running worker the new set:

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

// Vite: swap handlers in the running worker without a page reload.
if (import.meta.hot) {
  import.meta.hot.accept('./handlers', (mod) => {
    if (!mod) return;
    worker.resetHandlers(...mod.handlers);
    console.info('[msw] handlers reloaded —', mod.handlers.length, 'handler(s)');
  });
}

resetHandlers(...next) replaces the whole set atomically, which is the same guarantee the WireMock reset gives. The important consequence is that any runtime overrides added with worker.use() are discarded — that is correct behaviour, since those overrides were registered against the previous set, but it surprises people the first time.

Under Node (setupServer), the same idea applies with the runner’s watch mode:

// vitest.setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './src/mocks/handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Vitest reloads the whole module graph per run in watch mode, so no extra wiring is needed there — the reset in afterEach is about test isolation rather than hot-reload, and conflating the two is a common source of confusion.

Two reload paths for the same intent The WireMock path runs a file watcher outside the process, which posts to the admin mappings reset endpoint; WireMock re-reads the mounted directory and swaps its in-memory set while leaving the request journal intact. The MSW path uses the bundler's hot-update callback, which imports the new handler module and calls resetHandlers on the already-running worker, discarding any runtime overrides. WireMock — reload from outside the process edit mapping .json on the host inotifywait + debounce 150 ms quiet period POST /__admin/mappings/reset one atomic call set swapped request journal preserved MSW — reload from inside the bundle edit handlers.ts in the source tree import.meta.hot.accept bundler hands you the module worker.resetHandlers(...next) replaces the whole array no page reload runtime .use() overrides dropped Both paths replace the entire definition set in one operation, which is what makes them safe. Neither should run in CI.

3. Gate it out of CI

The watcher is a development affordance and a CI liability. Keep the flag explicit rather than inferring it:

# docker-compose.ci.yml — no watcher, definitions immutable
services:
  wiremock:
    image: wiremock/wiremock:3.13.2
    command: ["--global-response-templating", "--disable-banner"]
    ports: ["8080:8080"]
    volumes:
      # :ro is the guarantee — nothing in the run can alter the stub set.
      - ./wiremock/mappings:/home/wiremock/mappings:ro
      - ./wiremock/__files:/home/wiremock/__files:ro
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 6
      start_period: 10s

The read-only mount converts “we agreed not to reload in CI” into something the runtime enforces. This is the same immutability principle that managing mock server lifecycles in Docker applies to image tags.

Verification

# Confirm the reload actually replaced the set rather than appending to it
curl -s http://localhost:8080/__admin/mappings | jq '.mappings | length'
# edit a mapping, wait a moment, then:
curl -s http://localhost:8080/__admin/mappings | jq '.mappings | length'   # same count, new content

# Confirm the journal survived the reload
curl -s http://localhost:8080/__admin/requests | jq '.requests | length'   # unchanged

For MSW, the console line printed by the hot hook is the signal. If editing a handler produces no [msw] handlers reloaded line, the accept callback is not wired to the right module path — it must match the import specifier exactly.

Which reset endpoint clears what A grid of three reset endpoints against three kinds of state. The mappings reset endpoint replaces stub definitions and rewinds scenarios but keeps the request journal. The requests reset endpoint clears only the journal. The global reset endpoint clears all three, which is almost never what a mid-session reload intends. Endpoint Stub definitions Scenario state Request journal POST /__admin/mappings/reset reloaded from disk rewound to Started preserved POST /__admin/requests/reset untouched untouched cleared POST /__admin/reset reloaded from disk rewound to Started cleared Reach for the first row when reloading definitions — the journal is usually the thing you were about to inspect. Note that no row leaves scenario state alone: reloading definitions always rewinds a flow you were part-way through.

Gotchas and edge cases

  • Editor atomic saves look like deletions. Vim and many editors write a temp file and rename over the original, which surfaces as delete then create. A watcher that reloads on delete alone will briefly serve an empty stub set. Watch close_write,move,create together and debounce, as the script above does.

  • Reloading mappings in WireMock rewinds scenarios. /__admin/mappings/reset restores scenario state to Started along with the definitions. If you are mid-way through a multi-step flow, the reload silently sends you back to the beginning — which reads as a broken application rather than a reload side effect. Re-drive the scenario after reloading, or use /__admin/scenarios to inspect where you actually are.

  • A watcher inside a container may see nothing. Bind-mount file events do not always propagate into containers, particularly on macOS and Windows with virtualised filesystems. Run the watcher on the host and reach the container over the admin port, as the script does, rather than running inotifywait inside the container where the events may never arrive.


← Back to Mock Lifecycle Management