badmock
v0.1.0
Published
A zero-config, zero-dependency mock API that misbehaves like production. Shape any response on demand — and simulate a server under stress: rising latency, hanging connections, bad periods, and circuit breakers.
Maintainers
Readme
badmock
A mock API that's bad on purpose.
Every mock server gives you the happy path. badmock gives you production: latency that rises under load, requests that hang until your AbortController fires, bad minutes that come and go on their own, and a circuit breaker that trips and recovers. It's a stateful degradation simulation you point your frontend at — because the sad path is the part of your app you never get to test, until users find it for you.
Zero config, zero dependencies (~100 kB), nothing to import into your app — the interaction is pure HTTP. And underneath the chaos it's also a complete everyday mock API: shape any response on demand, serve fixed routes from a file.
See it in action
https://github.com/user-attachments/assets/46c6ce99-adbf-4b65-803c-cdcd04e45705
The page in the video is just a visual client — a plain HTML page sending ordinary HTTP requests to badmock running as npx badmock --simulate overloaded. Everything you see (the climbing latency, the failures, the recovery) is the server misbehaving; a frontend of your own pointed at the same URL experiences exactly the same thing.
| Button | What the server receives |
| --- | --- |
| 🔨 Hammer the API | 30 × plain GET /employees — nothing special about the requests; the rising latency is the server reacting to load on its own |
| 📡 Single request | 1 × GET /employees |
| ⚡ Force bad period | POST /api/sim/trigger with {"what":"bad_period","durationMs":10000} — 10 seconds of failures, hangs and amplified latency |
| 🔄 Reset | POST /api/sim/reset — closes the circuit breaker, ends the bad period, releases hung connections |
A few things the video doesn't show:
- Shape any response per request —
curl -H "X-Mock-Status: 404" -H "X-Mock-Delay: 800" http://localhost:3000/anything - Trip the circuit breaker on demand —
POST /api/sim/triggerwith{"what":"circuit_open"} - Other personalities —
--simulate flaky(frequent failures, touchy breaker) and--simulate unstable(hangs and network-style trouble) - Fixed routes + custom simulation from a
badmock.config.json
Contents: Quick start · How it works · What badmock simulates · vs json-server / MSW · The mock API · Recipes · Installation · Configuration reference · CLI · Programmatic API · FAQ
Quick start
npx badmock --simulate overloaded
# ▶ badmock listening on http://127.0.0.1:3000
# ⚡ simulation active (preset "overloaded")Point your frontend at it and use it like any API:
const res = await fetch("http://localhost:3000/products");
// sometimes fast, then slower as you hammer it, sometimes 503,
// sometimes… nothing at all, until your timeout fires. Like real life.Watch what the "server" is going through, live:
curl http://localhost:3000/api/sim/status
# → { "mode": "bad_period", "reqPerSec": 42, "addedLatencyMs": 2600,
# "circuit": { "state": "open", … }, "hungRequests": 3, … }Does your spinner ever resolve? Does your retry logic back off? Does anything tell the user? That's what badmock is for.
Just want a plain, well-behaved mock?
npx badmock(no flag) gives you the everyday mock API with no chaos at all — and you can turn the chaos on later without changing a line of app code.
How it works
badmock runs as its own server, separate from your frontend dev server. Two processes side by side:
| | What it is | Typically on |
| --- | --- | --- |
| Your frontend | Vite / Next.js / CRA dev server | localhost:5173 |
| badmock | this mock server | localhost:3000 |
Your app just makes normal HTTP requests to http://localhost:3000/.... CORS is enabled out of the box, so cross-origin calls from any local dev server work with zero setup, and every request is logged to the mock's console. Nothing is imported into your app bundle — the interaction is pure HTTP.
Responses come from the three mock layers (headers, config routes, /api/mock); the degradation simulation, when enabled, layers realistic failure conditions on top of all of them.
What badmock simulates
The simulation is global and stateful — a condition the whole server is in, evolving over time and with traffic, not a per-route delay: 500. Four behaviors compose:
| Behavior | What it does |
| --- | --- |
| Load-adaptive latency | Latency rises with measured requests/sec over a rolling window (a server saturating) and falls when traffic eases. Linear or exponential curve. |
| Bad periods | On a jittered schedule the server drops into a degraded window (~10s) where latency is amplified and requests may fail or hang, then it self-recovers. |
| Hanging connections | Some requests hold the socket open with no response at all until the client aborts. This exercises your client's own timeout/AbortController handling — something a fixed delay can never do. |
| Circuit breaker | After N consecutive failures the breaker flips open and rejects all traffic (503) for a cooldown, then half-opens to test recovery, then closes. |
A few deliberate design choices:
- Injected failures don't feed the breaker. Only organic downstream
5xx(from your routes/mocks) count toward tripping it — so bad periods and the breaker never cascade into a stuck loop. - Half-open trials get a clean shot (load latency only, no injected hang/fail) so the recovery test is faithful.
maxHangMs: 0means hang truly indefinitely (until the client gives up). Set it above0to fall back to a504after that long.- The reserved
/api/*endpoints stay exempt, so you can always observe and reset the simulation even while everything else is failing.
Presets
npx badmock --simulate overloaded # or: flaky, unstable| Preset | Character |
| --- | --- |
| overloaded | A server buckling under load — latency climbs fast (exponential), with occasional bad periods and a forgiving breaker. |
| flaky | An unreliable service — frequent failures during bad periods; the breaker trips readily and recovers quickly. |
| unstable | Network instability — connections hang, bad periods bring more hangs and failures, moderate latency. |
Presets are a starting point. Override any individual field with a simulation block in the config file (file values win over the preset). The simulation layers on top of whatever mocks you define — fixed routes and header-shaped paths alike.
Observe and control the simulation
So you can see what the simulation is doing, and force states deterministically for demos and tests:
# live state
curl http://localhost:3000/api/sim/status
# → {
# "enabled": true, "reqPerSec": 42, "mode": "bad_period",
# "badPeriod": { "active": true, "endsInMs": 6200, "nextInMs": 0 },
# "circuit": { "state": "open", "consecutiveFailures": 0, "openForMs": 11800 },
# "addedLatencyMs": 2600, "hungRequests": 3
# }
# force a bad period (optionally for N ms) or trip the breaker
# (Content-Type must be application/json so the body is parsed)
curl -X POST http://localhost:3000/api/sim/trigger \
-H "Content-Type: application/json" -d '{"what":"bad_period","durationMs":10000}'
curl -X POST http://localhost:3000/api/sim/trigger \
-H "Content-Type: application/json" -d '{"what":"circuit_open"}'
# clear everything (closes circuit, ends bad period, releases hung requests)
curl -X POST http://localhost:3000/api/sim/resetEvery response also carries observability headers you can read in the browser network tab:
X-Sim-Added-Latency— ms of latency the simulation added to a passed-through request.X-Sim-Reason— on an injected response:circuit_open,bad_period, orhang.
You can drive all of this from code too — force a breaker open in a test setup file and assert your client falls back correctly. See the Programmatic API.
Every simulation field, type, and default is listed in the Simulation config reference.
Why not json-server or MSW?
Use whichever fits — they solve different problems:
| | Best at | Failure simulation | | --- | --- | --- | | json-server | Instant CRUD REST API from a JSON file, with persistence | — | | MSW | Intercepting requests inside your app/tests (no separate server) | Static (per-handler delay/error you code yourself) | | Mockoon | GUI-designed mock APIs | Static (per-route latency/rules) | | badmock | Stateful degradation + per-request response shaping | Dynamic — a live, evolving server condition: load-adaptive latency, hangs, bad periods, circuit breaker |
If you need a persistent fake database, use json-server. If you want request interception without a process, use MSW. If you want to know how your frontend behaves when the backend is having a bad day — that's badmock.
A complete mock API underneath
The simulation needs something to degrade — so badmock is also a full, zero-config mock server. Even with the chaos off, it covers everyday frontend work. Three ways to define what comes back, from most dynamic to most fixed; they compose freely:
- Header/query shaping — hit any path, control the response per-request: any status, delay, error, header.
- Config-file routes — a stable, repeatable fake API with
:params. - The explicit mock endpoint — describe the full response in one JSON body.
Shape any endpoint with headers
Hit any path with any method and shape the response with X-Mock-* request headers (recommended) or _-prefixed query params (handy for pasting a URL in the browser). Your client code looks exactly like it's calling a real API.
await fetch("http://localhost:3000/orders/42", {
headers: { "X-Mock-Status": "500", "X-Mock-Delay": "1500" },
});# query-param form — just paste in the browser address bar
http://localhost:3000/orders/42?_status=500&_delay=1500Header and query reference
Headers win over query params when both are present.
| Header | Query param | Effect |
| --- | --- | --- |
| X-Mock-Status | _status | Force a status code (e.g. 404, 204, 418). |
| X-Mock-Delay | _delay | Delay in ms. Also accepts a range like 200-1000 → random within it. |
| X-Mock-Error | _error | Return an error body of a named type (below). Sets the matching status unless X-Mock-Status overrides it. |
| X-Mock-Fail | _fail | Probability 0..1 of randomly turning a success into an error. |
| X-Mock-Header-* | — | Set an arbitrary response header, e.g. X-Mock-Header-X-RateLimit: 99 → response gets X-RateLimit: 99. |
Named error types (X-Mock-Error / _error): unauthorized (401), forbidden (403), not_found (404), conflict (409), bad_request (400), custom_error (500).
Response body rules
- Success — echoes the JSON body you sent (great for
POST/PUT). With no body you get{ "ok": true }.POSTdefaults to201, everything else to200. Status204returns no content. - Error —
{ "error": "not found", "code": "NOT_FOUND" }.
Define a fixed API with a config file
When you want a stable fake API — real paths that always return the same data — drop a badmock.config.json next to where you run the command. It's auto-detected (or pass --config <file>).
{
// Raw value → returned as the response body with status 200
"GET /users": [{ "id": 1, "name": "Ada" }],
// ":id" params work — real path routing
"GET /users/:id": { "id": 1, "name": "Ada" },
// Full spec object → control status / body / delay / headers / error
"POST /login": { "status": 401, "body": { "message": "bad credentials" } },
"GET /slow": { "delay": "500-1500", "body": { "ok": true } },
"GET /missing": { "error": "not_found" }
}Two behaviors worth knowing:
X-Mock-*headers still override a configured route — e.g. force/usersto500to test error handling without editing the file.- Any path not in the config falls back to header/query shaping.
Route keys, every spec-object field, and the structured form (separate routes + simulation keys) are documented field-by-field in the Configuration reference. See badmock.config.example.json for a complete example.
The explicit mock endpoint
If you'd rather describe the entire response in a single JSON body instead of headers, POST to /api/mock. The body is a discriminated union on type.
Shared fields (both branches):
{
delay?: number; // ms (0..300000)
randomDelay?: boolean; // enable a random delay window
randomDelayMinMs?: number; // default 0
randomDelayMaxMs?: number; // default 2000
headers?: Record<string, string>;
allow_fail?: boolean; // if true, outcome may randomly flip
fail_probability?: number; // 0..1
}Success (type: "success") — responds { "ok": true, "data": <mockData> }:
{ type: "success", mockData?: unknown, status?: number /* 2xx */ }Error (type: "error") — responds { "error", "code", "details" }:
{
type: "error",
errorType: "unauthorized" | "forbidden" | "not_found" | "conflict" | "bad_request" | "custom_error",
status?: number, // 4xx/5xx override
error?: { message?: string, code?: string, details?: unknown }
}Notes: delay takes precedence over randomDelay; when allow_fail + fail_probability are set, the server picks the outcome randomly, then uses your success or error fields accordingly.
curl -X POST http://localhost:3000/api/mock \
-H "Content-Type: application/json" \
-d '{ "type": "error", "errorType": "not_found", "delay": 800 }'Recipes
Client timeout — make it hang and check your AbortController fires:
npx badmock --simulate unstablefetch("http://localhost:3000/report"); // may hang until your client abortsResilience demo — run --simulate overloaded, hammer the API, and watch X-Sim-Added-Latency climb via /api/sim/status.
Breaker fallback in a test — trip the breaker deterministically, assert your UI shows its degraded state, then reset (or use the programmatic API).
Loading spinner — add latency:
fetch("http://localhost:3000/dashboard", { headers: { "X-Mock-Delay": "2000" } });Auth redirect — force a 401:
fetch("http://localhost:3000/me", { headers: { "X-Mock-Error": "unauthorized" } });Retry logic — 30% random failures:
fetch("http://localhost:3000/flaky", { headers: { "X-Mock-Fail": "0.3" } });Rate-limit UI — inject a header:
fetch("http://localhost:3000/search", {
headers: { "X-Mock-Status": "429", "X-Mock-Header-Retry-After": "30" },
});Installation
Requires Node 18+. Three ways to run it:
1. Instantly with npx (no install):
npx badmock2. As a project dev-dependency with an npm script:
npm install -D badmock// package.json
{
"scripts": {
"mock": "badmock --port 3000"
}
}npm run mock3. Globally, if you use it across projects:
npm install -g badmock
badmockNote:
npm installonly copies files — you start the server by running thebadmockcommand. See the FAQ.
Configuration reference
Everything badmock reads at startup, in one place: where configuration comes from, the config-file shapes, every route field, the simulation block, and how sources layer.
Where configuration comes from
| Source | How | Notes |
| --- | --- | --- |
| Config file | badmock.config.json, auto-detected in the current working directory | Override the path with --config <file>; ignore an auto-detected file with --no-config. |
| CLI flags | See the CLI reference | Flags override the file. |
| Environment variables | PORT, HOST | Used as the default port/host when --port / --host aren't passed. |
The config file has two shapes:
// Flat — the whole object is a route map
{ "GET /users": [{ "id": 1 }] }// Structured — separate routes and simulation ("routes" and "simulation" are reserved keys)
{
"routes": { "GET /users": [{ "id": 1 }] },
"simulation": { "load": { "maxMs": 3000 } }
}Route definitions
Each entry maps a route key to a response.
Route key — "METHOD /path":
- The method is optional — omit it (
"/users") to match any method. - Paths support
:params, e.g."GET /users/:id".
Response value — either raw data or a spec object:
- Raw data (array, object, string, number) → returned verbatim as the body, status
200(201forPOST). - Spec object → any combination of these fields:
| Field | Type | Description |
| --- | --- | --- |
| status | number | Response status code. |
| body | any JSON | Response body. Wrap raw data here if it itself has a reserved key (status, delay, etc.). |
| delay | number | string | Delay in ms, or a "min-max" range string (e.g. "500-1500") → random within it. |
| headers | object | Response headers to set. |
| error | string | Named error type (unauthorized, forbidden, not_found, conflict, bad_request, custom_error); sets the matching status and a { error, code } body unless status/body override it. |
If neither status nor error is set, success defaults apply (201 for POST, else 200). X-Mock-* request headers still override a configured route at request time.
{
"GET /users": [{ "id": 1, "name": "Ada" }], // raw data
"GET /users/:id": { "id": 1, "name": "Ada" }, // :param
"POST /login": { "status": 401, "body": { "message": "bad credentials" } },
"GET /slow": { "delay": "500-1500", "body": { "ok": true } },
"GET /missing": { "error": "not_found" },
"DELETE /orders/:id": { "status": 204 } // any-method form also allowed: "/orders/:id"
}Simulation config reference
Add a simulation object (structured form) to enable the degradation simulation from the config file. All fields are optional; anything omitted falls back to the default below (or the preset, if one is active — file values win over the preset).
{
"simulation": {
"enabled": true,
"load": {
"baselineMs": 20, // latency floor added to every request
"windowMs": 1000, // rolling window used to measure req/sec
"ratePerSecForMax": 40, // req/sec at which added latency hits maxMs
"maxMs": 3000, // max added latency
"curve": "linear" // "linear" | "exponential"
},
"badPeriod": {
"enabled": true,
"everyMs": 60000, // roughly how often one starts (jittered 0.5x–1.5x)
"durationMs": 10000, // how long it lasts
"latencyMultiplier": 5, // latency ×5 while active
"failProbability": 0.4, // chance a request fails (503) while active
"hangProbability": 0.1 // chance a request hangs while active
},
"hang": {
"probability": 0, // baseline chance any request hangs
"maxHangMs": 0 // 0 = indefinite; >0 = fall back to 504 after this
},
"circuitBreaker": {
"enabled": true,
"failureThreshold": 5, // consecutive organic 5xx to open
"openMs": 15000, // cooldown before half-open
"halfOpenTrialRequests": 1, // requests allowed through to test recovery
"rejectStatus": 503 // status returned while open
}
}
}Precedence
- Routes:
startServer({ routes })option → config file. - Simulation: preset (
--simulate) → config-filesimulationblock → explicitstartServer({ simulation })(later wins). - Per request:
X-Mock-*headers /_queryparams override a matching config route.
CLI reference
badmock [options]| Flag | Description |
| --- | --- |
| -s, --simulate [preset] | Enable degradation simulation. Preset is overloaded (default), flaky, or unstable. |
| --no-simulate | Disable simulation even if the config enables it. |
| -p, --port <n> | Port to listen on. Default 3000 (or $PORT). |
| --host <h> | Host/interface to bind. Default 127.0.0.1 (or $HOST). |
| -c, --config <file> | Load routes/simulation from a JSON file. Auto-detects ./badmock.config.json. |
| --no-config | Ignore an auto-detected config file. |
| -v, --version | Print the version. |
| -h, --help | Show help. |
Stop the server with Ctrl+C.
Built-in endpoints
Reserved /api paths sit alongside the catch-all mock and are exempt from the simulation:
| Method | Path | Description |
| ---: | --- | --- |
| GET | /api/sim/status | Live simulation state. |
| POST | /api/sim/trigger | Force a bad period or trip the breaker. |
| POST | /api/sim/reset | Clear all simulation state and release hung requests. |
| POST | /api/mock | Explicit response simulator (full spec in the JSON body). |
| GET | /api/status | Health check → { "status": "OK" }. |
Programmatic API
You can also drive the mock from code (e.g. a test setup file). The package exports:
import {
startServer, // boot the server; returns an http.Server with `.simEngine`
createApp, // build the app (no listen)
loadConfig, // read + parse a config file
normalizeConfig, // split a loaded config into { routes, simulation }
findConfig, // auto-detect a config path in a directory
PRESETS, // the built-in simulation presets
DegradationEngine,
} from "badmock";startServer(options):
{
port?: number; // default $PORT or 3000
host?: string; // default $HOST or "127.0.0.1"
silent?: boolean; // suppress the "listening" log line
log?: boolean; // per-request logging (default true unless silent)
routes?: RouteConfig; // fixed routes, already parsed
configPath?: string; // load routes + simulation from a file
simulation?: SimConfigInput; // explicit simulation overrides
simulatePreset?: string; // "overloaded" | "flaky" | "unstable"
}Example — start a mock around a test run and drive the simulation directly:
import { startServer } from "badmock";
const server = startServer({ port: 4000, silent: true, simulatePreset: "flaky" });
// force states deterministically in a test
server.simEngine?.trigger("circuit_open");
// ... assert your client falls back correctly ...
server.simEngine?.reset();
server.close(); // stops the server and the engine's timersFAQ
Does npm install start the server? No. Installing only copies files into node_modules. Start it by running the badmock command (npx badmock, an npm script, or node). A package that spawned a server on install would be surprising and unsafe.
Where does the config file live? In your project — in the directory where you run the command (typically next to your package.json), not inside the badmock package.
Is the config hot-reloaded? Not yet — it's read once at startup. Edit it and restart to pick up changes. Hot reload is on the roadmap.
Hanging vs. a big delay — what's the difference? A delay responds eventually (after N ms). A hang never responds; the socket stays open until the client aborts. Only a hang exercises your client's timeout/AbortController path.
Does it call or test my real API? No, never. badmock is a fake API that you point your frontend at; it makes no outbound requests.
Any dependencies? None. It's built on Node's built-in http module and installs in ~100 kB.
Roadmap
- Config hot-reload — pick up
badmock.config.jsonchanges without a restart. - Scenario scripting — a timeline of simulation states ("2 min healthy, then degrade, then outage") for demos and E2E runs.
- Request assertions — optionally record received requests so tests can assert what the frontend sent.
Have a use case? Open an issue — see CONTRIBUTING.md.
Local development
npm install
npm run dev # hot-reload dev server (ts-node-dev on src/server.ts)
npm run build # compile TypeScript to dist/
npm test # build + run the node:test suite against dist/
npm start # run the built CLI (node bin/cli.js)Tests use Node's built-in test runner — no framework, keeping the package at zero dependencies. They run against the compiled dist/, i.e. exactly what ships to npm.
The published package ships only the compiled dist/, the CLI in bin/, the example config, this README, and the license.
License
MIT — see LICENSE.
