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.
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.
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.
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
deletethencreate. A watcher that reloads ondeletealone will briefly serve an empty stub set. Watchclose_write,move,createtogether and debounce, as the script above does. -
Reloading mappings in WireMock rewinds scenarios.
/__admin/mappings/resetrestores scenario state toStartedalong 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/scenariosto 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
inotifywaitinside the container where the events may never arrive.
Related
- Managing Mock Server Lifecycles in Docker — startup, health gating and teardown around this reload
- Inspecting the WireMock Request Journal — the journal that a mappings reload deliberately preserves
- Running WireMock in Docker Compose — the mounted volumes this technique depends on
← Back to Mock Lifecycle Management