pure-effectv0.23.0 GitHub →
New in 0.23A flight recorder for every production run: pure-effect/flight-recorder records each run with one call at startup.See how →

Reproduce a production bug on your laptop, no database required.

Pure Effect records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript. Your logic returns plain objects that describe the I/O it would do, instead of doing it. A test reads them without a mock, and a request that failed in production runs again on your machine, with the answers it got and nothing connected.

$ npm install pure-effect
Terminal session: timeTravel replays a recorded checkout flow, narrating three recorded steps and ending in a Failure, with no database and no network
A recorded production failure, replayed locally with timeTravel(checkoutFlow, trace).
Release notes What's new in 0.23.0
  • pure-effect/flight-recorder ships. enableRecording records every run of an application with one call at startup, one recorder per run, and replaces the recording example you used to copy. Already installing hooks? Pass recordingHooks to configureEffect instead.
  • Retry refuses a third argument. It used to ignore one, so Retry(effect, options, next) dropped its next without a word. Now it throws as the flow is built and tells you where the step goes.
01  /  problem

It works on your machine. It broke in production. You can't reproduce it.

Business logic and I/O are tangled together. When you write await db.findUser(email), the call fires right there, in the middle of the logic. So a test can only check what happened by making the call happen too, against a mock, a fake or a container. And when production fails, all you have is a stack trace: the answers the request actually got were never kept.

async / await: your code makes the call

// The database call happens right here.
async function registerUser(input) {
  const found = await db.findUser(input.email);
  if (found) throw new Error('Email in use');
  return db.saveUser(input);
}

// To test it you must run it. When it fails
// in production, nothing was kept to replay.

You check behaviour by running it. The failed run leaves nothing you can step through.

Pure Effect: your code returns the call

// Read what it would do first. Nothing ran.
const flow = registerUserFlow(input);
assert.equal(commandName(flow), 'cmdFindUser');

// Hand it an answer and check what it decides.
const next = flow.next(null);
assert.equal(commandName(next), 'cmdSaveUser');

// Rerun a request recorded in production,
// with the answers it got and no database.
const again = registerUserFlow(trace.initialInput);
await replayEffect(again, trace);

You check it by reading the flow. A request that failed in production runs again here, with no database.

02  /  record & replay

Record every run. Keep the ones that failed. Replay them anywhere.

Install the flight recorder once, at startup. It records each run without changing a call site, keeps the runs worth looking at, and hands each one to your storage as plain JSON. Later, anywhere, the same flow replays the trace step by step, with nothing connected.

1 · at startup

Turn it on once.

import { enableRecording } from 'pure-effect/flight-recorder';

enableRecording({
  sink: (trace) => putObject(
    `traces/${trace.flowName}/${randomUUID()}.json`,
    JSON.stringify(trace)),
  version: process.env.BUILD_ID,
  redact: hidePasswords,
});

One recorder per run, so requests running at the same time never share a trace. By default it keeps a run that failed, or in which a Command threw, and drops the rest.

2 · in production

Run flows as usual.

// The only change: name the run.
const result = await runEffect(
  checkoutFlow(input),
  context,
  { flowName: 'checkout' },
);

// Values pass through redact first, and
// a storage failure never changes the result.

Observing a run never decides it. A sink that throws goes to onSinkError; the request keeps its own outcome.

3 · on your laptop

Replay the incident.

import { timeTravel } from 'pure-effect';

const trace = JSON.parse(await readTrace('8f3a'));

// Narrates each step. No database, no network.
await timeTravel(checkoutFlow, trace, {
  version: process.env.BUILD_ID,
});

Given the build, timeTravel warns when the trace came from another one. A flow that changed shape stops with a TimeParadox naming where.

What timeTravel prints
Replaying 'checkout': 3 recorded steps
Initial input: { "cartId": "cart_abc123", "promoCode": "FREE_YEAR_VIP" }
├─ cmdFetchCart returned in 41.3ms {"totalAmount":"120.00"}
├─ cmdValidatePromo returned in 26.8ms {"isValid":true,"discountValue":100}
└─ cmdChargeCard threw in 181.5ms {"code":"invalid_amount", ...}
Replay finished with state: Failure
Then keep it as a test
it('incident 8f3a: a 100% promo charges $0', async () => {
  const { result } = await replayEffect(
    checkoutFlow(trace.initialInput), trace);
  assert.equal(result.error.code, 'invalid_amount');
});

Runs where your server runs. The flight recorder needs AsyncLocalStorage, so it runs in Node, Deno, Bun and Cloudflare Workers with nodejs_compat, not in a browser. It ships as its own entry point, so the 13 KB core stays free of imports. Already running OpenTelemetry? Pass recordingHooks(options) to configureEffect beside your telemetry hooks.

03  /  building blocks

Six pieces. Learnable in an afternoon.

Every part of a flow is one of these shapes. They compose into a flow that runEffect walks at the edge of your app, the only place a call is made.

i.

Success(value)

ok

A step that worked. Returns { type: 'Success', value }, which the next step receives.

ii.

Failure(error)

decided, stops

An outcome the flow decided, such as an email already in use. The flow stops there and nothing retries it. It carries only its error, exactly as written.

iii.

Command(cmd, next?, meta?)

I/O, described

A call described as data. cmd is the function that would run; next turns its result into the next step. Its name is how traces, replays and spans know it.

iv.

Ask(next?)

request values

Reads the context passed to runEffect, such as the tenant or the request id, from any step, without passing it along.

v.

Retry(effect, options?)

try again

Runs a step again when its call throws. Set retries, delay and backoff, and give up gracefully with onExhausted. A returned Failure is never retried.

vi.

Parallel(effects, next?, opts?)

at the same time

Runs branches at the same time and hands their results to next in order. The first failure cancels the rest; limit caps how many run at once, and settled runs every branch to the end.

effectPipe(...steps): steps in a line effectGen(function*) / step: written like async code runEffect(flow, context?, callConfig?): runs it configureEffect(...configs): hooks, layered recordEffect / replayEffect / timeTravel: record and replay enableRecording / recordingHooks: from pure-effect/flight-recorder
Complete example: user registration
import { Success, Failure, Command, effectPipe, runEffect, commandName } from 'pure-effect';

// Pure. No I/O, tested by calling it.
const validateRegistration = (input) => {
  if (!input.email.includes('@')) return Failure('Invalid email.');
  if (input.password.length < 8) return Failure('Password too short.');
  return Success(input);
};

// A Command describes the call. It does not make it.
const ensureEmailAvailable = (input) => {
  const cmdFindUser = () => db.findUser(input.email);
  return Command(cmdFindUser, (found) => (found ? Failure('Email already in use.') : Success(input)));
};

// With no next, the result passes straight through.
const saveUser = (input) => {
  const cmdSaveUser = () => db.saveUser(input);
  return Command(cmdSaveUser);
};

const registerUserFlow = (input) => effectPipe(validateRegistration, ensureEmailAvailable, saveUser)(input);

// Test: read the flow. No mocks, no I/O.
const flow = registerUserFlow({ email: '[email protected]', password: 'password123' });
assert.equal(commandName(flow), 'cmdFindUser');
assert.equal(commandName(flow.next(null)), 'cmdSaveUser');

// Run: the only place a call is made.
const result = await runEffect(registerUserFlow(input));
Or write the same flow as a generator
import { effectGen, step } from 'pure-effect';

// yield* step(...) where async code writes await. The steps are the same functions.
const registerUserFlowGen = effectGen(function* (input) {
  yield* step(validateRegistration(input));
  yield* step(ensureEmailAvailable(input));
  return yield* step(saveUser(input));
});

// The same Commands as the pipeline, so a trace recorded from one replays against the other.
assert.equal(commandName(registerUserFlowGen(input)), 'cmdFindUser');
04  /  features

What you get out of the box.

Test without a database

Assert what your code would do.

A flow is plain objects. Hand a step an answer and check what it returns: no mock, no in-memory fake, no container.

assert.deepEqual(flow.next(null), Success(input));
Time-travel debugging

Step through the failed request locally.

Replay a trace through the same flow and retrace the exact path it took: narrated, timed, and with no I/O. Errors replay faithfully, causes included.

const { trace } = await recordEffect(checkoutFlow(input));
await timeTravel(checkoutFlow, trace); // nothing connected
Retry, tested without waiting

Try again, then fall back.

A call that throws runs again; when every try fails, onExhausted runs a fallback whose result feeds the rest of the flow. A replay skips the delays, so a test runs at once.

Retry(fetchLive(sku), { retries: 3, onExhausted: () => fetchCached(sku) });
Parallel

Branches at the same time, results in order.

The first failure cancels the other branches, and a Command that takes a parameter gets an AbortSignal to stop early. Cap how many run at once with limit, or collect every outcome with settled.

Parallel([getUser(id), getPermissions(id)]); // [user, permissions]
Undo, reported

Undo what succeeded when a branch fails.

Charged the card, then the stock ran out? The guide's undoAll runs the undo steps as a settled Parallel: every one runs, and one that fails is reported beside the original error instead of lost.

undoAll([refundCharge(chargeId)], 'out_of_stock');
// refund failed? Failure({ error: 'out_of_stock', undoFailed: ['refund_failed'] })
Request values

Context without threading it through.

Pass the tenant, request id or config once, to runEffect, and read it from any step with Ask.

Ask((ctx) => loadPlan(ctx.tenant)); // ctx comes from runEffect
TypeScript

Every error the flow can end in, inferred.

The error union is collected across every step with no annotations, and a flow that reads context will not compile without it. Bundled declarations, TypeScript 5.1 and later.

result.error; // 'Invalid email.' | 'Email already in use.' | Error
Hooks & OpenTelemetry

Tracing and recording share the hooks.

onRun, onStep and onBeforeCommand wrap a run without touching your logic. Layers merge, so spans and the flight recorder install together. Spans carry names and timings, never values.

configureEffect(telemetryHooks(), recordingHooks({ sink }));
05  /  where it fits

Start where an incident is expensive. Keep logic out of handlers everywhere.

Pure Effect uses the same record-and-replay idea as durable execution engines, minus the server. It pays off on the flows where a production failure costs hours of guesswork: payments, bookings, webhooks, sync jobs. It also keeps a CRUD handler honest, where logic piles up next to the queries unnoticed. Flows run beside existing async code, so you can adopt it one flow at a time.

Alternative
What it gives you
Reach for Pure Effect when…
async/await + fakes
The default. It tests well only by habit: nothing marks where a call happens, so one lands in the middle of a decision, and fakes drift from the real service. It also cannot give you the request that failed in production, replayed with the answers it got.
…you want every call to be a visible step, and an incident in this flow to be replayable, not guesswork from logs.
Logs & tracing
A timeline of what happened: names, timings, errors. It shows the request, but cannot run its decisions again, or become a test.
…you want the failed request back, runnable. Keep both: they share the hooks.
Temporal & durable execution
Record and replay as an execution guarantee: histories persist on a server, and workflows survive crashes and sleep for days. You operate a cluster or a cloud service.
…the work starts and finishes within a request, and you want nothing to operate.
Effect-TS
A full functional ecosystem with fibers, streams, schemas and structured concurrency. Powerful, with a steep learning curve and a vocabulary of its own.
…you need testable flows, retry and replay, learnable in an afternoon.

Good fit

  • Several calls in a row, where the order and the answers matter
  • Retries, fallbacks and undo that have to be right
  • Failures that are rare in production and hard to reproduce
  • Handlers where business rules creep in beside the queries

Not what it's for

  • Workflows that sleep for days or survive restarts
  • Making concurrent writes safe: idempotency keys, compare-and-set and transactions are still your design
06  /  ai

Hand the AI last night's incident. It can't touch production.

The trace the flight recorder kept is the only thing that enters the replay sandbox. Inside it, an agent runs the failing request again as many times as it needs: read what happened step by step, edit the flow, replay again. The functions that talk to your database and your payment provider never run during a replay: not mocked, not disabled, simply never called. When the replay passes, the incident leaves the sandbox as a regression test.

IN PRODUCTION a checkout fails flight recorder trace REPLAY SANDBOX no call to the outside world can run replay the trace see each step's outcome edit the flow wrong? go again on success the incident, now a test runs in CI forever
One trace enters, one test leaves. Being wrong in here costs a second of CPU.

The same property covers writing code, not just fixing it: because a flow is plain data, AI-written code can be checked by reading the flow before anything runs.

async / await: run to check

// Did the AI handle the error path?
// You won't know until you run it.
async function registerUser(input) {
  const found = await db.findUser(input.email);
  if (found) throw new Error('Email in use');
  return db.saveUser(input);
}

You check by running. Every check touches infrastructure.

Pure Effect: read to check

// An AI wrote this flow. Read it before it runs.
const flow = registerUserFlow(input);
assert.equal(commandName(flow), 'cmdFindUser');

// A user already exists: it must stop, not save.
assert.deepEqual(flow.next({ id: 7 }), Failure('Email already in use.'));

You check by reading the flow. No database, no network.

Safety is unconditional; confirmation is not. A fix that changes the shape of the flow stops at the exact point it leaves the recording, and still runs no I/O. The worst an agent can do in the sandbox is be wrong.

07  /  install

Six building blocks.
One flight recorder.
Zero dependencies.
See what your code does before it runs.

$ npm install pure-effect