Saga pattern workflow library for Node.js, browser and React Native. Typed builder · idempotency keys · retry with exponential backoff · compensation on failure · distributed locking · crash recovery. Zero runtime dependencies.
📚 Docs · 🚀 Getting started · 🧩 Recipes · ❓ FAQ · 📈 Download trends · 📦 npm · 🐙 GitHub
npm install kompensa
pnpm add kompensa
yarn add kompensa
bun add kompensaRequires Node.js 18+. Works in modern browsers, Deno, Bun, and React Native (Hermes). Ships with ESM + CJS and full TypeScript declarations.
import { createFlow } from 'kompensa';
const checkout = createFlow<{ orderId: string }>('checkout')
.step('reserve', {
run: async (ctx) => reserveStock(ctx.input.orderId),
compensate: async (_ctx, r) => releaseStock(r.id),
})
.step('charge', {
run: async (ctx) => chargeCard(ctx.input.orderId, ctx.results.reserve.total),
compensate: async (_ctx, c) => refund(c.id),
retry: { maxAttempts: 3, backoff: 'exponential', initialDelayMs: 200 },
timeout: 5_000,
})
.step('invoice', {
run: async (ctx) => issueInvoice(ctx.results.charge.id),
});
await checkout.execute(
{ orderId: '42' },
{ idempotencyKey: 'order-42' },
);If charge fails → reserve is compensated automatically (saga rollback).
Re-running with the same idempotencyKey → returns the cached result (no duplicate charge).
Process crashed mid-flow? → resumes from the last successful step (with a durable adapter).
kompensa is a small, type-safe saga pattern library for JavaScript and TypeScript that handles the four hardest problems of multi-step workflows in production:
- Idempotency — re-running the same work with the same key returns the cached result. No more duplicate payments, duplicate orders, or duplicate emails when clients retry.
- Retry with exponential backoff and jitter — transient failures (timeouts, 429, 503) are retried intelligently. Permanent failures (400, business errors) stop immediately.
- Compensation (the Saga pattern) — when a step fails, previous successful steps are rolled back automatically in reverse order. No partial state leaks.
- Crash recovery — every step transition is persisted. If the process dies mid-flow, the next invocation resumes from the last completed step.
It's an in-process, lightweight alternative to Temporal, Cadence, or AWS Step Functions — designed for teams that need structured saga handling inside a Node.js service, a React Native app, or a BullMQ worker, without adopting a heavyweight orchestrator.
| Problem | Without kompensa | With kompensa |
|---|---|---|
| Duplicate payment / duplicate order bugs | Client retries, customer billed twice | idempotencyKey → result cached, side effects not replayed |
| Partial failure leaks (stock reserved, charge failed) | Orphaned state, manual cleanup, on-call pages | Saga compensate runs on downstream failure, in reverse order |
| Dumb retry loops (hammering APIs on 4xx) | Infinite retries, rate-limit bans | PermanentError stops retries; TransientError retries w/ jitter |
| Chain of hung requests (one stuck call blocks all) | Whole flow frozen | Per-step timeout, StepTimeoutError retryable by default |
| Crash = lost work | Retry the whole flow from scratch | Resume from the last successful step (durable adapter) |
| Multi-worker racing same key | Both execute, both charge | Distributed lock via Postgres advisory / Redis Redlock |
✅ Use kompensa when:
- You're building an HTTP endpoint that touches 2+ services and needs to be safely retried by the client (checkout, signup, booking)
- You're processing jobs in a queue (BullMQ, pg-boss, SQS) where the broker may re-deliver and you need dedup + rollback
- You're building an offline-first mobile app (React Native) that syncs pending work when connectivity returns
- You want saga pattern semantics in TypeScript without adopting Temporal's worker infrastructure
- You need idempotency keys at the HTTP layer (Stripe-style
Idempotency-Keyheader) - You want typed retry, compensation and state machine primitives — not hand-rolled try/catch
❌ Look elsewhere when:
- You need workflows that span days or weeks with human-in-the-loop steps → use Temporal
- You need signal routing and cross-workflow orchestration at scale → Temporal or Step Functions
- You're happy with plain try/catch in a 2-step flow — no need to add a dependency
import { Pool } from 'pg';
import { createFlow, FlowError } from 'kompensa';
import { PostgresStorage } from 'kompensa/storage/postgres';
const storage = new PostgresStorage({ pool: new Pool({ connectionString }) });
await storage.ensureSchema();
const checkout = createFlow<CheckoutInput>('checkout', { storage })
.step('reserve', { run: reserveStock, compensate: releaseStock })
.step('charge', { run: charge, compensate: refund, retry: { maxAttempts: 3 } })
.step('invoice', { run: issueInvoice });
app.post('/checkout', async (req, res) => {
try {
const result = await checkout.execute(req.body, {
idempotencyKey: req.header('Idempotency-Key'),
});
res.json(result);
} catch (err) {
if (err instanceof FlowError) {
res.status(409).json({ failedAt: err.failedStep, message: err.message });
}
}
});import { Worker } from 'bullmq';
import { createFlow } from 'kompensa';
import { RedisStorage } from 'kompensa/storage/redis';
const flow = createFlow('process-order', {
storage: new RedisStorage({ client: redis }),
lockWaitMs: 0, // if another worker has this job, don't queue behind
})
.step('charge', { run: charge, compensate: refund })
.step('ship', { run: ship });
new Worker('orders', async (job) => {
return flow.execute(job.data, { idempotencyKey: `order-${job.data.id}` });
});import { createFlow, TransientError } from 'kompensa';
import { SqliteStorage } from './SqliteStorage'; // 30-line adapter, see docs
const syncOrder = createFlow<{ orderId: string }>('sync-order', {
storage: new SqliteStorage(db), // survives app force-close
})
.step('upload', {
run: async (ctx) => api.post('/orders', ctx.input),
retry: { maxAttempts: 5, backoff: 'exponential', initialDelayMs: 500 },
})
.step('markSynced', {
run: async (ctx) => localDb.mark(ctx.input.orderId, 'synced'),
});
NetInfo.addEventListener(async (net) => {
if (net.isConnected) {
for (const o of await pendingOrders()) {
syncOrder.execute({ orderId: o.id }, { idempotencyKey: `sync-${o.id}` });
}
}
});| Capability | kompensa | Temporal | AWS Step Functions | BullMQ | Plain try/catch |
|---|---|---|---|---|---|
| In-process orchestration | ✅ | ❌ | ✅ | ||
| Zero runtime dependencies | ✅ | ❌ | ❌ | ❌ | ✅ |
| Works in browser / React Native | ✅ | ❌ | ❌ | ❌ | ✅ |
| Saga compensation (auto rollback) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Idempotency out of the box | ✅ | ✅ | ✅ | ❌ | |
| Crash recovery / resume | ✅ (w/ adapter) | ✅ | ✅ | ❌ | |
| Distributed locks | ✅ | ✅ | ✅ | ✅ | ❌ |
| Long-running (days / weeks) | ❌ | ✅ | ✅ | ❌ | |
| Typed DSL (TypeScript) | ✅ | ❌ | ❌ | — | |
| Bundle size | ~20 KB | 10+ MB server | — | ~200 KB | 0 |
| Setup time | 2 minutes | 2 hours | 1 hour | 30 min | 0 |
Short version: kompensa sits between "roll your own" and "adopt a heavyweight orchestrator". If your workflows finish in seconds to minutes and live inside a single service, kompensa is enough. If you need cross-process, long-running durable state, reach for Temporal.
| Runtime | Supported | Notes |
|---|---|---|
| Node.js 18 / 20 / 22 | ✅ | CI runs all three |
| Bun | ✅ | ESM + CJS, no Node-specific APIs in core |
| Deno | ✅ | ESM import, use npm:kompensa |
| Browsers (modern) | ✅ | Core is isomorphic, MemoryStorage works |
| React Native (Hermes) | ✅ | Needs polyfill for structuredClone on very old versions |
| Cloudflare Workers | ✅ | Core works; storage adapter needs to be KV-backed |
| Framework / tool | Integration | Example |
|---|---|---|
| Express / Fastify | ✅ | Wrap endpoint in flow.execute() with idempotencyKey |
| Next.js App Router | ✅ | Route handler or server action |
| NestJS | ✅ | Inject flow via provider |
| BullMQ / Bull | ✅ | flow.execute(job.data, { idempotencyKey: job.id }) |
| pg-boss | ✅ | Same pattern as BullMQ |
| AWS SQS / Lambda | ✅ | Lambda handler wraps flow |
| Expo / React Native | ✅ | SqliteStorage / MMKV adapter for offline |
| Storage adapter | Built-in | Package | Use for |
|---|---|---|---|
| Memory | ✅ | kompensa |
tests, single-process, offline |
| PostgreSQL | ✅ | kompensa/storage/postgres + pg |
most backends |
| Redis | ✅ | kompensa/storage/redis + ioredis |
when Redis is already in stack |
| SQLite / AsyncStorage/ MMKV | 🔌 DIY | your adapter (~30 lines) | mobile / RN offline |
| DynamoDB, MongoDB, … | 🔌 DIY | your adapter | other clouds |
| Feature | Status |
|---|---|
| Fluent builder with typed result accumulation | ✅ |
| Idempotency via execution key | ✅ |
| Retry — fixed / linear / exponential + jitter | ✅ |
| Saga compensation (reverse order on failure) | ✅ |
| Per-step timeout | ✅ |
| AbortSignal cancellation | ✅ |
| Lifecycle hooks (start/retry/end/compensate) | ✅ |
| Persistent state (resume after crash) | ✅ |
MemoryStorage adapter |
✅ |
PostgresStorage with advisory lock |
✅ v0.2 |
RedisStorage with Redlock-style lock |
✅ v0.2 |
| Distributed lock (multi-worker safety) | ✅ v0.2 |
| Parallel step groups (fan-out/fan-in) | 🗓️ v0.3 |
| OpenTelemetry adapter | 🗓️ v0.3 |
useFlow() React hook |
🗓️ v0.3 |
Steps declare run and an optional compensate. Results accumulate into ctx.results — fully typed:
createFlow<{ email: string }>('signup')
.step('createUser', {
run: async (ctx) => db.users.create({ email: ctx.input.email }),
compensate: async (_ctx, user) => db.users.delete(user.id),
})
.step('sendEmail', {
run: async (ctx) => mailer.send(ctx.results.createUser.id, 'welcome'),
// ^^^^^^^^^^ statically typed
});Retry policy (per step or flow-wide):
retry: {
maxAttempts: 3,
backoff: 'exponential', // 'fixed' | 'linear' | 'exponential'
initialDelayMs: 100,
maxDelayMs: 30_000,
jitter: true, // true | false | 0..1 fraction
shouldRetry: (err, n) => err.code !== 'E_FORBIDDEN',
}Throw PermanentError to dead-stop retries; TransientError is always eligible.
Idempotency states:
| Prior state | Behavior |
|---|---|
success |
Returns cached result, skips all steps |
compensated |
Re-throws the original FlowError |
running (crash) |
Resumes from the first non-success step |
| (none) | Normal execution |
See docs/concepts.md for the full state machine, lock protocol, and observability hooks.
Pass a stable idempotencyKey (typically the Idempotency-Key HTTP header, the order ID, or a business-unique value):
await checkout.execute(body, { idempotencyKey: req.header('Idempotency-Key') });The first call runs. Subsequent calls with the same key return the cached result without re-running the flow — so your payment provider is only hit once.
Every step can declare a compensate function — the semantic inverse of run. If a later step fails, kompensa walks backwards through successful steps and calls each one's compensate. Example:
reserve (✅) → charge (✅) → ship (❌ fails)
↓ compensation runs automatically
refund ← release stock
This is the Saga pattern. Your FlowError contains both the original failure and any errors that occurred during rollback.
Temporal is a heavyweight durable-workflow platform with its own worker infrastructure, history storage, and replay-based execution model — great for multi-day workflows across fleets of workers. kompensa is a small in-process library: no worker, no history server, no replay model. Workflows live in your Node process and finish in seconds to minutes. Use Temporal when you need cross-worker coordination over hours/days; use kompensa when you want saga semantics without the ops cost.
Yes — kompensa and BullMQ compose naturally. BullMQ handles job scheduling and distribution; kompensa handles the inside of each job. The distributed lock prevents two workers from processing the same job ID at once, and idempotency means broker re-delivery is safe:
new Worker('orders', async (job) => {
return flow.execute(job.data, { idempotencyKey: `order-${job.data.id}` });
});Yes. Use it in any route handler, server action, or API route. Plug in PostgresStorage for crash recovery across deploys and a distributed lock that prevents two Next.js instances from racing on the same key.
Yes. That's exactly one of its target use cases. Write a small StorageAdapter backed by SQLite (expo-sqlite) or MMKV (~30 lines). When the app is backgrounded or force-closed mid-sync, the next launch resumes from the last successful step — no duplicate server-side work.
Every step transition is persisted to the storage adapter before the step runs. If the process dies between step B and step C, the next invocation with the same idempotencyKey loads the persisted state and resumes at step C. Step B does not run again (its side effects happened once). Use a durable adapter (PostgresStorage or RedisStorage) for real crash recovery — MemoryStorage is wiped on restart.
.step('callApi', {
run: async (ctx) => fetch(url, { signal: ctx.signal }),
retry: {
maxAttempts: 5,
backoff: 'exponential',
initialDelayMs: 200,
maxDelayMs: 30_000,
jitter: true, // recommended — prevents thundering herd
},
timeout: 10_000,
})Transient errors retry automatically. Throw PermanentError to stop retries on 4xx responses.
Use MemoryStorage in unit tests — no database required:
import { createFlow, MemoryStorage, FlowError } from 'kompensa';
const releaseSpy = vi.fn();
const flow = createFlow('x', { storage: new MemoryStorage() })
.step('reserve', { run: () => ({ id: 'r1' }), compensate: () => releaseSpy() })
.step('charge', { run: () => { throw new Error('fail'); } });
await expect(flow.execute({})).rejects.toBeInstanceOf(FlowError);
expect(releaseSpy).toHaveBeenCalledOnce();See docs/testing.md for more patterns.
kompensa runs 73 tests (50 unit + 23 integration) against real Postgres 17 and Redis 7 in CI — covering concurrency, crash recovery, lock TTL expiry, token-safe release, and resume-after-crash. It's pre-1.0, so minor versions may still have breaking API changes; pin exact versions until 1.0 or read the CHANGELOG.
// In-memory (default) — tests, single-process, offline
import { MemoryStorage } from 'kompensa';
// Postgres — JSONB state + pg_advisory_lock
import { Pool } from 'pg';
import { PostgresStorage } from 'kompensa/storage/postgres';
const storage = new PostgresStorage({ pool: new Pool({ connectionString }) });
await storage.ensureSchema();
// Redis — SET NX PX with Lua-safe release
import Redis from 'ioredis';
import { RedisStorage } from 'kompensa/storage/redis';
const storage = new RedisStorage({ client: new Redis(REDIS_URL) });Both durable adapters survive worker crashes:
- Postgres — advisory lock releases when the holding connection closes
- Redis — token-verified Lua release script, TTL server-enforced
Writing a custom adapter (SQLite / DynamoDB / KV / in-memory / etc.) is a 3-method interface plus an optional lock. See docs/storage-adapters.md for the decision matrix and a 30-line SQLite example for mobile.
import {
createFlow, Flow,
FlowError, FlowAbortedError, LockAcquisitionError,
PermanentError, TransientError, StepTimeoutError,
MemoryStorage, createMemoryStorage,
silentLogger, consoleLogger,
computeDelay, shouldRetryError,
isPermanent, isTransient, serializeError,
} from 'kompensa';
import type {
StepContext, StepDefinition, RetryPolicy,
ExecuteOptions, FlowConfig,
FlowStatus, StepStatus, FlowState, StepState, SerializedError,
StorageAdapter, Lock, AcquireLockOptions, Logger, FlowHooks,
FlowStartEvent, FlowEndEvent,
StepStartEvent, StepEndEvent, StepRetryEvent,
CompensateEvent,
} from 'kompensa';See docs/api.md for the full reference, or the TSDoc in the source.
- v0.2 ✅ — durable storage (Postgres, Redis) · distributed locks · integration tests
- v0.3 — parallel step groups (fan-out/fan-in) · OpenTelemetry adapter ·
useFlow()React hook - v0.4 — scheduler integration (cron / delayed retries) · SQLite adapter for mobile
Track progress in issues and milestones.
git clone https://github.com/sirelves/kompensa.git
cd kompensa
npm install
npm test # 50 unit tests
npm run test:services:up # spin up Postgres + Redis containers
npm run test:integration # 23 integration tests
npm run typecheck # tsc --noEmit
npm run build # tsup → dist/PRs welcome. Please run npm test + npm run typecheck before submitting.
MIT © sirelves