Type-safe, zero-dependency, framework-agnostic rollback for multi-step operations in TypeScript & JavaScript.
Register a rollback for each step; if anything fails, they run in reverse — automatically.
- 🪶 Lightweight — tiny footprint, zero dependencies.
- 🔒 Type safe — written in TypeScript, ships with full types.
- ↩️ Reverse-order rollback — compensating operations run newest-first (LIFO), the right order to unwind dependent steps.
- 🧩 Two ergonomic APIs — a
withRollbackscope that cleans up for you, or acreateRollbackinstance you drive by hand. - 🔗 Action + rollback in one call —
step()runs a forward action and registers its compensation together, registering the rollback only if the action succeeds (and threading its result into the rollback). - ⏱️ Timeouts & cancellation — bound a single
stepor the whole operation; aTimeoutErroris thrown and anAbortSignalis fired so in-flight work can cancel and rollback still runs even when a call hangs. - 🛟 Failure-aware — collect every rollback failure, or stop at the first; left-over operations are handed back so you can log or retry.
- 🪢 Progressive commit —
commit()seals the current batch and stays open, so independent units of work can share one flow without sharing fate. - 🌐 Framework agnostic — plain functions, no runtime lock-in. Works with any stack: Express, Fastify, Next.js, NestJS, serverless, or no framework at all.
- 📦 ESM & CJS — works in both module systems, Node 18+, and the browser.
npm install rollbackitpnpm add rollbackityarn add rollbackitbun add rollbackit- Features
- Install
- Quick start
- When to use it
- Usage
- API
- Behavior notes
- FAQ
- Tech Stack
- Contributing
- License
import { withRollback } from "rollbackit";
const result = await withRollback(async (rb) => {
const user = await db.createUser(data);
rb.add("delete user", () => db.deleteUser(user.id)); // rollback for the step above
await sendWelcomeEmail(user); // if this throws, "delete user" runs, then the error re-throws
return user; // success → nothing is rolled back
});That's the whole idea: register a rollback right after each step. On success, rollbacks are discarded; on failure, they run newest-first and the original error propagates.
Prefer to run the action and register its rollback in one call? Use step, which also unlocks per-step and whole-operation timeouts:
const user = await withRollback(async (rb) => {
// step runs createUser, and registers the rollback only if it succeeds
return rb.step(
"create user",
(signal) => db.createUser(data, { signal }),
(user) => db.deleteUser(user.id),
{ timeout: 5_000 },
);
});Use rollbackit when:
- A sequence of side effects must be all-or-nothing, but they span systems a single database transaction can't cover (DB + object storage + search index + third-party APIs).
- You're implementing the saga pattern / compensating transactions in application code and don't want a full workflow engine.
- You want cleanup logic to live next to the step it reverses, instead of in a far-away
catch.
Reach for something else when:
- Everything happens in one database — use a native DB transaction; it's atomic, this isn't.
- You only need to release local resources (file handles, sockets) —
try/finallyorusing/AsyncDisposableStackmay be enough. - You need durable, crash-surviving orchestration with retries across restarts — use a real saga/workflow engine (Temporal, AWS Step Functions, etc.). rollbackit is in-memory and lives for the duration of one process.
Wraps your steps in a scope (see Quick start above). If the
callback succeeds, the scope is committed and nothing is rolled back. If it
throws, the registered operations run automatically in reverse order before the
original error is re-thrown. Steps with no side effect to roll back simply don't
register an add.
Because the original error propagates, withRollback does not return the
rollback failures. Pass onFailures to observe them (log, alert, metrics):
await withRollback(
async (rb) => {
/* ... */
},
{
onFailures: ({ failures, pending }) =>
logger.warn("rollback incomplete", { failures, pending }),
},
);When you need to drive the lifecycle yourself:
import { createRollback } from "rollbackit";
const rb = createRollback();
try {
const created = await db.createUser(data);
rb.add("delete user", () => db.deleteUser(created.id));
await storage.createBucket(created.id);
rb.add("delete bucket", () => storage.deleteBucket(created.id));
rb.commit(); // all good — keep the changes
} catch (error) {
const { failures } = await rb.rollback(); // roll back in reverse order
if (failures.length) {
logger.warn("rollback incomplete", failures); // operations that threw while rolling back
}
throw error;
}add registers a rollback for work you've already done — which means you have to
get the ordering right by hand: do the thing, then register its compensation,
and never let an add slip in front of the action it reverses. step collapses
that into one call. It runs the forward action and only registers the rollback if
the action resolves, so a failed step never leaves a compensation pointing at
something that was never created.
const user = await rb.step(
"create user", // names the step; appears in RollbackFailure if the rollback throws
() => api.createUser(payload),
(user) => api.deleteUser(user.id), // receives the result of the forward action
);step returns whatever run returns and calls rollback with that value, so you
don't thread ids through outer variables. If run throws, nothing is registered
and the error propagates (an enclosing withRollback then unwinds the earlier
steps). options adds stopOnFailure (as on add) and timeout (below).
A slow call is a correctness problem, not just a latency one: if your process is
killed while a step hangs — a Lambda hitting its timeout, a pod SIGKILL'd past
its grace period — control never reaches your catch, so rollback never runs
and you leak the resources created so far. Give the work its own deadline that
fires first, a few seconds below the platform's, so the unwind happens while
you're still alive.
Bound a single step with StepOptions.timeout (as in Quick start),
or the whole operation with WithRollbackOptions.timeout — or both. The
whole-operation budget is the safety net to set under a platform limit; fn
receives an AbortSignal to thread into your slow calls:
await withRollback(
async (rb, signal) => {
const user = await rb.step(
"create user",
() => api.createUser(payload),
(user) => api.deleteUser(user.id),
);
await api.activate(user, { signal }); // thread the scope signal into slow calls
},
{ timeout: 25_000 }, // whole-operation budget, a few seconds below the platform limit
);Both throw TimeoutError (a RollbackError subclass); a timed-out withRollback
still unwinds whatever was registered before re-throwing.
Pass the signal into your calls. A timeout stops you waiting, but only the
AbortSignal can stop the in-flight work — and only if your call honors it
(fetch(url, { signal }), most drivers/SDKs). step passes it to run,
withRollback passes it to fn as the second argument; ignore it and the request
keeps running in the background. (You'll catch TimeoutError in practice — the one
exception is a run that rejects synchronously on abort, where its own error
wins the race. Either way nothing is registered, so don't branch cleanup on
instanceof TimeoutError.)
A timed-out action is left in an unknown state — it may have created the resource on the server before the abort landed, yet no rollback was registered for it. Make such actions idempotent or reconcilable, and give the timeout enough margin that a genuine success isn't cut off.
commit() doesn't have to run at the end. Call it mid-flow at the pivot —
the step after which rolling back the earlier work would be wrong (money moved, an
event was published, an irreversible action happened). Everything registered so
far is sealed; a later failure rolls forward (retry, alert), never back.
const rb = createRollback();
try {
const order = await db.createOrder(data);
rb.add("delete order", () => db.deleteOrder(order.id));
await inventory.reserve(order);
rb.add("release stock", () => inventory.release(order));
// Pivot: once the card is charged, we're committed to fulfilling —
// rolling back the order now would be worse than the inconsistency.
await payment.charge(order);
rb.commit(); // seal everything; do not roll back from here
// Post-pivot work. If this throws, rollback() is a no-op — handle it forward.
await email.sendReceipt(order);
return order;
} catch (error) {
await rb.rollback(); // only rolls back if we threw *before* commit (before charging)
throw error;
}commit() seals everything registered so far and drops those rollbacks. Work you
register after it starts a fresh batch that's still reversible — see
Batches in one flow below.
commit() doesn't finalize the instance — it seals the current batch and
stays open. Each commit draws a line: a later rollback() only unwinds the
operations registered since the last commit. This lets independent units of
work share one flow without sharing fate — no nesting required.
const rb = createRollback();
// stage one — two side effects, rolled back together if this batch fails
async function stageOne() {
const user = await db.createUser(data);
rb.add("delete user", () => db.deleteUser(user.id));
const bucket = await storage.createBucket(user.id);
rb.add("delete bucket", () => storage.deleteBucket(bucket.id));
}
// stage two — an independent batch
async function stageTwo() {
const sub = await billing.subscribe(plan);
rb.add("cancel subscription", () => billing.cancel(sub.id));
}
try {
await stageOne();
rb.commit(); // stage one succeeded — seal it; its rollbacks are dropped
await stageTwo(); // throws here? only stage two rolls back — stage one stays
rb.commit();
} catch (error) {
await rb.rollback(); // unwinds only the batch in progress
throw error;
}This works inside withRollback too — the rb it hands your callback is the
same instance, so committing mid-callback seals a batch and a later throw
unwinds only what came after it (on success withRollback commits the final
batch for you):
await withRollback(async (rb) => {
await stageOne(rb);
rb.commit(); // seal stage one — survives even if stage two throws
await stageTwo(rb); // throws? only stage two rolls back, then re-throws
});Reach for the manual createRollback form over nesting withRollback when the
batches are sequential or data-driven (a loop, a pipeline, N stages decided
at runtime): it keeps the flow flat and lets your control flow set the
boundaries. The trade-off is the point: once a batch is committed it's
permanent — rollback() never reaches past a commit line.
Creates a rollback instance.
| Member | Type | Description |
|---|---|---|
add(description, rollback, options?) |
(string, () => Promise<void>, options?: { stopOnFailure?: boolean }) => void |
Register a rollback operation. Pass { stopOnFailure: true } to halt the unwind if this operation's rollback throws (see below). Throws RolledBackError if called after rollback (after commit is fine — see below). |
step(description, run, rollback, options?) |
<T>(string, (signal: AbortSignal) => Promise<T>, (result: T) => Promise<void>, options?: StepOptions) => Promise<T> |
Run run, and only if it resolves, register rollback (called with run's result); returns run's result. If run throws or times out, nothing is registered and the error propagates. Throws RolledBackError if called after rollback. |
commit() |
() => void |
Seal the current batch: treat the work so far as permanent and drop its rollbacks. The instance stays open for the next batch. Safe to call multiple times. |
rollback(options?) |
(options?: RollbackOptions) => Promise<RollbackResult> |
Run the operations registered since the last commit, in reverse order, and finalize the instance. Returns the failures and any pending (un-run) operations. Safe to call multiple times; subsequent calls are no-ops. |
StepOptions (extends the per-operation { stopOnFailure }):
| Option | Type | Default | Description |
|---|---|---|---|
timeout |
number |
— | Abort run after this many milliseconds and reject with TimeoutError (usually — see notes). The AbortSignal passed to run fires on timeout. No rollback is registered. |
stopOnFailure |
boolean |
false |
Halt the unwind if this step's rollback throws (per-operation, same as add). |
RollbackOptions:
| Option | Type | Default | Description |
|---|---|---|---|
stopOnFailure |
boolean |
false |
Stop at the first rollback operation that throws instead of unwinding the rest. |
RollbackResult:
| Field | Type | Description |
|---|---|---|
failures |
readonly RollbackFailure[] |
Operations that threw while rolling back ({ description, error }). |
pending |
readonly RollbackOperation[] |
Operations never run because stopOnFailure halted early (carries the rollback fns, so you can log or retry them). Empty unless an early stop occurred. |
Runs fn(rollback, signal) within a scope: commits on success, rolls back in
reverse order on failure (then re-throws the original error). WithRollbackOptions
extends RollbackOptions with:
| Option | Type | Description |
|---|---|---|
timeout |
number |
Whole-operation budget in ms. If fn doesn't settle in time, the scope rolls back and a TimeoutError is thrown. fn receives an AbortSignal (2nd arg) that fires on timeout. Bounds the forward work only, not the unwind. |
onFailures |
(result: RollbackResult) => void |
Called with the RollbackResult when fn throws and one or more rollback operations also throw while unwinding. Observation hook — it must not throw; any error it throws is ignored so it can't mask the original error. |
- Reverse order — rollbacks run newest-first (LIFO), the correct order to unwind dependent steps.
- Failures don't stop the sequence — by default a throwing rollback operation is collected into
result.failuresand the remaining operations still run. SetstopOnFailure: trueto halt at the first failure; the older, un-run operations are returned inresult.pending(use this only when compensations are ordered dependencies). You can also set it per operation viaadd(description, rollback, { stopOnFailure: true })to halt only if that specific operation's rollback throws; the run-level flag, whentrue, halts on every failure regardless. - Commit seals, rollback finalizes —
commit()seals the current batch and keeps the instance open, so you can register a new batch after it (see Batches in one flow). Onlyrollback()finalizes the instance;addafter a rollback throwsRolledBackError. Repeatcommit/rollbackcalls are safe no-ops. - The original error always wins —
withRollbackre-throws whateverfnthrew, never a rollback error. Observe rollback failures viaonFailures(or the returnedRollbackResultwithcreateRollback).
When should I use withRollback vs createRollback?
Prefer withRollback — it scopes the lifecycle for you (commit on success, roll back on throw) and is the right fit for ~90% of cases. Drop to createRollback when you need manual control over when to commit or roll back, or to inspect the RollbackResult directly.
What happens if a rollback operation itself throws?
It's recorded in result.failures and the remaining operations still run, so one bad rollback doesn't strand the rest. Set stopOnFailure: true to halt instead; whatever was left un-run comes back in result.pending.
Is this a replacement for database transactions? No. If all your work is in one database, use a native transaction — it's truly atomic. rollbackit is for distributed side effects across systems that have no shared transaction (DB + storage + search + external APIs), where the only way to reverse a step is to run a compensating action.
Does rollback run in parallel?
No — operations roll back sequentially, newest-first, which is the safe default for dependent steps. If you have independent cleanups you want concurrent, compose them inside a single rollback function: rb.add("cleanup", () => Promise.allSettled([a(), b()])).
What if a step has nothing to roll back?
Don't call add. Only register a rollback for steps that created a side effect worth reversing (pure reads, validation, etc. register nothing).
Does it work with CommonJS / ESM / the browser? Yes to all — it ships both ESM and CJS builds with full type declarations, targets Node 18+, and has no Node-specific dependencies, so it runs in the browser too.
Is it safe to call rollback() or commit() more than once?
Yes. commit() is repeatable — each call seals the current batch and leaves the instance open for more (see Batches in one flow). rollback() finalizes the instance and subsequent calls are no-ops (returning an empty result). Only add() after a rollback() throws — RolledBackError.
Built with tech/tools that I enjoy using:
- TypeScript - for type safety and developer experience.
- Vitest - for testing.
- Biome - for linting and formatting.
- Changesets - for versioning and publishing.
- pnpm - for package management.
- GitHub Actions - for CI/CD.
- Tsdown - library bundler powered by Rolldown.
- Lefthook - for pre-commit hooks.
- CodeRabbit - for PR-level GitHub code review.
Contributions are welcome! Please open an issue or pull request.