npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rollbackit

v1.0.1

Published

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.

Readme

Features

  • 🪶 Lightweight — tiny footprint, zero dependencies.
  • 🔒 Type safe — written in TypeScript, ships with full types.
  • ↩️ Reverse-order undo — 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.
  • 🛟 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)); // undo 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 an undo right after each step. On success, undos are discarded; on failure, they run newest-first and the original error propagates.

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 undo 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(); // undo in reverse order
  if (failures.length) {
    logger.warn("rollback incomplete", failures); // operations that threw while undoing
  }
  throw error;
}

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 undoing 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 undoes if we threw *before* commit (before charging)
  throw error;
}

commit() seals everything registered so far and drops those undos. 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, undone 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 undos 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). | | commit() | () => void | Seal the current batch: treat the work so far as permanent and drop its undos. 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. |

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) 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 | | --- | --- | --- | | 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 undo 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 "undo" 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 undo? 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