Skip to content

alexmarqs/rollbackit

Repository files navigation

rollbackit logo

rollbackit

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.

CI npm version zero dependencies

Features

  • 🪶 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 withRollback scope that cleans up for you, or a createRollback instance you drive by hand.
  • 🔗 Action + rollback in one callstep() 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 step or the whole operation; a TimeoutError is thrown and an AbortSignal is 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 commitcommit() 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.

Install

npm install rollbackit
pnpm add rollbackit
yarn add rollbackit
bun add rollbackit

Contents

Quick start

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 },
  );
});

When to use it

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/finally or using / AsyncDisposableStack may 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.

Usage

withRollback (recommended)

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 }),
  },
);

createRollback (manual control)

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;
}

Pairing a step with its rollback (step)

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).

Timeouts (don't let a hung step skip rollback)

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.

Committing early (point of no return)

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.

Batches in one flow (progressive commit)

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.

API

createRollback(): Rollback

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.

withRollback<T>(fn, options?): Promise<T>

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.

Behavior notes

  • 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.failures and the remaining operations still run. Set stopOnFailure: true to halt at the first failure; the older, un-run operations are returned in result.pending (use this only when compensations are ordered dependencies). You can also set it per operation via add(description, rollback, { stopOnFailure: true }) to halt only if that specific operation's rollback throws; the run-level flag, when true, halts on every failure regardless.
  • Commit seals, rollback finalizescommit() seals the current batch and keeps the instance open, so you can register a new batch after it (see Batches in one flow). Only rollback() finalizes the instance; add after a rollback throws RolledBackError. Repeat commit/rollback calls are safe no-ops.
  • The original error always winswithRollback re-throws whatever fn threw, never a rollback error. Observe rollback failures via onFailures (or the returned RollbackResult with createRollback).

FAQ (For humans and AI agents)

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.

Tech Stack

Built with tech/tools that I enjoy using:

Contributing

Contributions are welcome! Please open an issue or pull request.

License

MIT © Alexandre Marques

About

Automatic rollback for multi-step operations. Register an undo next to each step; if a later one throws, they unwind in reverse. Zero-dependency, type-safe.

Topics

Resources

License

Stars

Watchers

Forks

Contributors