A testing toolkit for finding bugs caused by duplicate, delayed, reordered and concurrent event deliveries.
If a webhook arrives twice, out of order, or at the same time as its twin, does your application still end up in the right state? EventLab turns that question into a test you can run in the runner you already have.
Everything documented below is implemented and tested on Linux, Windows and macOS — including the console block further down, which is compared against the library's own output on every CI run. Install it from source today; see Install. Anything not documented below is not built; see Roadmap.
Here is a payment webhook handler. It is the version you write after reading the provider's documentation about duplicate deliveries.
async function handlePayment(event: PaymentEvent) {
if (!(await store.claimEvent(event.eventId))) return; // already seen this event
await store.recordFulfillment(event.orderId);
}The deduplication is real, and claimEvent is genuinely atomic. Redeliver the
same event a hundred times and it fulfils once.
It is still wrong. Event-id deduplication answers "have I seen this message
before?", and the invariant the business cares about is "has this order been
fulfilled before?". Providers routinely send more than one event for a single
occurrence — Stripe describes both payment_intent.succeeded and
charge.succeeded for one payment — and two different event ids sail straight
past the check. The customer's order is fulfilled twice.
Every HTTP request returned 200. Nothing in a log or a status dashboard looks
wrong.
import { assertRunPassed, burst, createPlan, duplicate, runPlan, shuffle } from "@masterplaycoding/eventlab";
const plan = createPlan({
scenario: "payments delivered three times",
events: paymentEvents, // two orders; one of them described by two events
seed: 20260908,
concurrency: 4,
transforms: [duplicate({ copies: 3 }), shuffle(), burst()],
});
const report = await runPlan(plan, {
events: paymentEvents,
target: {
baseUrl,
request: ({ event }) => ({
method: "POST",
path: "/webhooks",
headers: { "content-type": "application/json" },
body: JSON.stringify(event.body),
}),
},
hooks: { reset: () => store.reset() },
assertions: [
{
name: "each order is fulfilled exactly once",
check: () => {
const writes = store.writeCount();
if (writes !== 2) throw new Error(`expected 2 fulfilment writes, found ${writes}`);
},
},
],
});
assertRunPassed(report); // throws with the block below✗ each order is fulfilled exactly once
expected 2 fulfilment writes, found 3
9 deliveries, all 200 OK
seed 20260908 · planner 2 · fixtures sha256:603b…
async function handlePayment(event: PaymentEvent) {
if (!(await store.claimEvent(event.eventId))) return;
await store.recordFulfillmentOnce(event.orderId);
}One line different, and the difference is which thing is unique. Claiming the
event id stays, because dropping redeliveries early is cheap — but it is an
optimisation, not the guarantee. recordFulfillmentOnce is a conditional
insert: a unique constraint on order_id, or INSERT … ON CONFLICT DO NOTHING.
It makes fulfilment idempotent per order, whatever combination of events
describes it.
The identical scenario now passes. Both handlers, both tests and the store live
in examples/duplicate-handler; run them with
npm test.
Note what this failure does not depend on: how those nine deliveries
interleaved. It reproduces identically on every platform and every run. A test
that only catches a bug when the scheduler cooperates is not a regression test —
see the determinism boundary, and
examples/voting for how a genuinely concurrent bug is
demonstrated without hoping.
npm install --save-dev @masterplaycoding/eventlabTo work on EventLab itself, or to depend on an unreleased change:
git clone https://github.com/MasterplaYCoding/EventLab.git
cd EventLab && npm ci && npm run buildcd /path/to/your-project
npm install --save-dev /path/to/EventLab/packages/coreThe build step is not optional: the package's exports point at dist/, so an
unbuilt clone resolves to nothing and gives you ERR_MODULE_NOT_FOUND with no
hint as to why.
Requires Node 22 or 24 — those are what CI covers; the engines floor is
>=22. ESM only: there is no CommonJS build, so require() will fail, and
TypeScript consumers need "moduleResolution": "node16", "nodenext" or
"bundler". No runtime dependencies. No cloud account, no Docker and no
database is needed to run the default example.
All four examples live in this repository and run from its root:
git clone https://github.com/MasterplaYCoding/EventLab.git
cd EventLab && npm ci && npm testexamples/duplicate-handler— the README's bug, in broken and corrected form.examples/voting— a genuinely concurrent bug, adapted from a real project, demonstrated without hoping for a race.examples/barrier-settlement— a refund that must not be applied before its payment settles, which needs a barrier to test at all.examples/inbox-outbox— durable inbox/outbox processing that survives a worker killed mid-transaction. Usesnode:sqlite, built into Node, so it still needs no Docker and no install.examples/restart— a handler that deduplicates in memory. Correct for as long as the process keeps running, and wrong the moment it is replaced.
See docs/quickstart.md — define fixtures, point at a target, choose a scenario, assert an invariant, run it in your existing runner.
It is a way to generate a repeatable delivery plan, execute it against an HTTP target, run your assertions about your application's state, and report what happened.
It is not a webhook gateway, a load generator, a network fault injector, or anything that inspects your database for you. Tools already exist for the neighbouring problems: Hookdeck for webhook forwarding during development, Toxiproxy for TCP-level conditions. EventLab's subject is the state your application ends up in.
| What EventLab guarantees | Where that stops |
|---|---|
| The same seed and inputs produce the same plan: the same attempts, payloads, offsets and ordering constraints. | It does not make execution deterministic. Network latency, database interleavings and your application's scheduling still vary between runs. See docs/decisions/001-determinism-boundary.md. |
| Every request your target receives corresponds to an attempt in the plan. There are no automatic retries and redirects are not followed. | It cannot account for retries your own HTTP client or proxy performs. |
| A delivery timeout is reported as a timeout, with the elapsed time. | A timeout is not evidence the server did not commit the write. See docs/decisions/002-timeouts-prove-nothing.md. |
| Reports never contain request bodies, and record request header names only — enforced by delivering a real signature, bearer token and card number and finding them in neither the JSON nor the formatted output. A response body is read up to the limit and then abandoned, so an endless one costs the limit rather than the request timeout. | Response body previews are captured (bounded, 64 KiB by default). The request URL is recorded whole, query string included — a provider that authenticates by query parameter puts that in the report. If your target echoes secrets, redact them in your own handler. Abandoning the body means the connection is not reused; that is the intended trade. |
| Deliveries go to loopback only. | Set allowRemoteTargets: true to override — deliberately explicit, because duplicating and bursting traffic at a shared host is not something to do by accident. |
| A saved plan replays the same instructions, and refuses to run against edited fixtures or a different planner version. | It cannot reproduce a race that depended on machine timing. Use a barrier and a checkpoint for that. |
| The target address is resolved once per phase, so an application restarted at a barrier is reached at the port it came back on. The loopback check runs on every resolution. | Within a phase the address is fixed - deliveries there are concurrent and must all reach one process. A restart mid-phase is not something EventLab can express, and pretending otherwise would report deliveries against a process that had gone. |
| A barrier releases nothing from a later phase until every attempt in the earlier one has settled, and its checkpoint has run. | Within a phase, ordering is still the network and your application's to decide. A barrier bounds a run into stages; it does not make a stage deterministic. |
| Milestone | Contents | State |
|---|---|---|
0.1 |
Planner, transforms, HTTP runner, assertions, JSON reports, saved plans, report formatting, both examples | implemented |
0.2 |
Explicit barriers, a barrier-only example, durable inbox/outbox crash recovery, and the eventlab CLI with an HTML timeline |
released |
0.3 |
Restarting the application mid-scenario, and an example that needs it | implemented, release pending |
0.4 |
Benchmarks, recipes for unfamiliar frameworks | planned |
1.0 |
Stable API and report schema, compatibility policy, complete recipes | planned |
- Quickstart — including how to get your app onto loopback.
- The command line —
run,replay,report, and the HTML timeline. - Concepts
- API reference — every export, the error codes, the limits.
- Troubleshooting — start here when a run confuses you.
- Writing a transform
- What deterministic replay does and does not mean
- Why a timeout does not prove a write failed
- How inbox/outbox transactions behave across crashes
- Source provenance
Small, real contribution opportunities are listed in CONTRIBUTING.md. Security reports go through SECURITY.md.
MIT. See LICENSE and PROVENANCE.md for retained notices on adapted code.