Long-running work that
actually finishes.

The thing nobody wants to build: a job runner that survives deploys, retries the right step the right number of times, doesn't double-charge on a redrive, and lets you sleep a workflow for a week without holding a process open. Voltro ships it — built on @effect/workflow + @effect/cluster, the same primitives Effect-TS apps use in production today.

onboarding.workflow.tsx
TypeScript
// onboarding.workflow.tsx — DESCRIPTOR (browser-safe)
import { workflow } from '@voltro/workflow/define'
import { Schema } from 'effect'

export const Onboarding = workflow({
  name:    'user.onboarding',
  payload: { userId: Schema.String },
  success: Schema.Struct({ ok: Schema.Boolean }),
})

// onboarding.workflow.server.tsx — EXECUTOR (server-only)
import { step, sleep } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import { generateText } from '@voltro/ai'
import type { AppContext } from '@voltro/runtime'

export default (ctx: AppContext) => ({ userId }) =>
  Effect.gen(function* () {
    const user = yield* step({
      name:    'load-user',
      success: Schema.Struct({ email: Schema.String, firstName: Schema.String }),
      execute: Effect.tryPromise(() =>
        ctx.store.select('users').where('id', userId).one()),
    })

    yield* step({
      name:    'send-welcome',
      execute: Effect.tryPromise(() =>
        ctx.store.insert('outbox', { to: user.email, template: 'welcome' })),
    })

    // Crash here? Resume replays from the journal —
    // the welcome won't be re-sent. Durable, not Effect.sleep.
    yield* sleep({ name: 'nudge-delay', duration: '1 day' })

    const nudge = yield* generateText({
      prompt: `Write a 1-sentence nudge for ${user.firstName}.`,
    })
    yield* step({
      name:    'send-nudge',
      execute: Effect.tryPromise(() =>
        ctx.store.insert('outbox', { to: user.email, body: nudge.text })),
    })

    return { ok: true }
  })

Six things you don't have to write.

A workflow file declares its payload schema, success schema, and an executor. Everything below is provided — no helper code, no third-party SDK, no separate queue process.

Resumable steps

Each step()-wrapped call records its result. On crash, deploy, or replica failover the next run replays the journal — completed steps return their cached value, only the failing step re-executes.

Idempotency keys

Pass an idempotencyKey (payload → string) to a workflow. Concurrent starts with the same key collapse to one execution — no duplicate sends, no race-condition double-charges.

Sleep + delayed continuation

Effect.sleep("1 day") inside a workflow suspends durably. The runtime parks the execution, persists the wake-up time, and resumes on a different replica days later if your fleet rotated.

External signals

awaitSignal(ctx, name) parks the workflow until a human (or another service) POSTs a matching signal. The wait is durable; restarts don't lose pending approvals.

Cron + scheduled runs

*.cron.tsx files declare clock-triggered handlers with a mandatory IANA timezone. Multi-instance coordination is built-in: postgres advisoryLock by default, or cluster shard ownership for the same model the workflow engine uses.

Cluster-aware

Built on @effect/cluster. Run on one box (single coordinator) or a fleet of any size (shard-distributed). Workflows migrate between runners on death. SQL-backed state is the only requirement.

The four-system integration you don't have to wire.

Without a durable workflow primitive, every long-running task forces a four-way decision: pick a queue (Bull, Sidekiq, SQS), pick a scheduler (k8s CronJob, EventBridge), pick a coordination story (advisory lock, leader election), and pick a state store for resumption (Postgres, Redis, etc). Each layer has its own auth, observability gap, and on-call page. Workflow primitives collapse it to one declaration.

When to reach for a workflow:

  • Multi-step business logic that must survive a crash (onboarding, checkout fulfilment, refund flow).
  • External API calls you can't safely re-do on retry (Stripe charge, email send, AI call you pay for per token).
  • Anything that needs to wait (delayed continuation, scheduled follow-up, human approval).
  • Periodic work (nightly invoice generation, hourly metrics roll-up, weekly digest).

The same multi-step job, both ways.

A queue, a worker, and your own retry bookkeeping
Assembled
// Enqueue it and hope.
await queue.add('fulfil', { orderId }, {
  attempts: 3,
  backoff: { type: 'exponential', delay: 1000 },
})

// worker.ts — a SECOND deployment to run and scale
new Worker('fulfil', async (job) => {
  await charge(job.data.orderId)      // if the process dies HERE…
  await reserveStock(job.data.orderId)
  await sendEmail(job.data.orderId)   // …the retry charges again
})
Steps that survive the process
Built in
export default defineWorkflow({
  name:  'orders.fulfil',
  input: Schema.Struct({ orderId: OrderId }),

  run: function* (ctx, { orderId }) {
    // Each step is journalled as it completes. A restart resumes
    // from the next one — it does not replay the ones behind it.
    yield* ctx.step('charge',  () => charge(orderId))
    yield* ctx.step('reserve', () => reserveStock(orderId))
    yield* ctx.step('notify',  () => sendEmail(orderId))
  },
})

The left column is missing the hard part on purpose, because most versions of it are: what happens when the process dies between the charge and the email. Answering that is a journal, an idempotency key per step, and a resume path — which is what the right column is, written once for every workflow instead of once per job.

What a crash actually costs.

  1. 1

    Each step commits its result

    A step's outcome is journalled to your database as it completes, in the same transaction as the work where the work is a database write.

  2. 2

    The process dies

    A deploy, an out-of-memory kill, a spot instance reclaimed. Nothing special happens here — that is the point.

  3. 3

    A replica picks the run up

    The run is claimed by whichever process is healthy. Claiming is a lease, so two replicas cannot both resume the same run.

  4. 4

    It resumes, it does not replay

    Completed steps are read from the journal rather than re-executed. The customer is charged once, and the email that had already gone does not go twice.

Durable execution, in depth.

What makes a workflow durable?

Each step's result is journaled before the next one runs. If the process dies mid-way — a deploy, an OOM kill, a node disappearing — the run resumes on another instance from the last completed step rather than from the beginning. Work already done is not repeated, and work not yet done is not lost.

That property is what turns a multi-step business process into something you can reason about. Charge the card, provision the account, send the welcome email: without durability, a crash between steps two and three leaves a customer who paid and has nothing, and the recovery is a human reading logs.

Because replay is how resumption works, a step must be the unit of non-determinism: anything that talks to the outside world, generates a random value or reads the clock belongs inside a step, so its result is journaled rather than recomputed differently on the second pass.

How do workflows wait for a human?

With a signal. A run can await an external event — an approval, a webhook, a document upload — and the framework offers two shapes: one that waits while holding its worker slot, and one that SUSPENDS the run entirely and frees the slot until the signal arrives.

The distinction matters at human timescales. A three-day approval that holds a worker is a slot you cannot use for three days; multiply by a queue of pending approvals and the fleet is idle-but-full. The framework warns once per workflow when a declared timeout is long enough that the suspending variant is probably what you meant.

It warns rather than swapping the variant for you, because the two journal differently and silently changing that mid-history is a replay trap. A hint you can act on beats a rewrite you did not ask for.

What happens when a step fails?

Retries are declarative: a policy on the step, with backoff, rather than a loop you write. A step that keeps failing eventually sends the run to a dead-letter state where it can be inspected and replayed, instead of vanishing or spinning forever.

Compensation — undoing the effects of earlier steps when a later one fails — is ordinary code placed inside a step, so it is journaled like everything else. There is no separate saga DSL to learn, and no second execution model with its own failure semantics.

Flow control is declarative too. A workflow can declare concurrency limits, throttling, debouncing or replacement of an in-flight run, and the engine admits or defers starts accordingly — the coordination that otherwise becomes a lock table somebody maintains by hand.

Does this need extra infrastructure?

No. The journal lives in your database, in framework-owned tables that migrate with the rest of your schema. There is no separate workflow service to deploy, no queue broker to operate, and no second datastore whose consistency you have to reason about against your own.

Scale-out is the same code. With several instances the engine coordinates so a run executes once, work is picked up by whichever instance is free, and a replica that dies has its runs resumed elsewhere. Nothing in the workflow changes between one container and twenty.

On deployments where remote changes reach the change stream, a signal or transition on one replica wakes the others immediately rather than waiting for the next storage poll — so cross-replica latency is not bounded by a polling interval, while the poll remains as the safety net.

What the workflow engine handles for you.

Durable workflow capabilities and how they are expressed
CapabilityHow it works
Journaled stepsEach step's result is persisted before the next runs; a crash resumes from the last completed step.
SignalsAwait an external event, holding a worker or suspending the run entirely for human-length waits.
RetriesDeclarative policy with backoff; exhausted runs land in a dead-letter state for inspection and replay.
CompensationOrdinary code inside a step, journaled like any other — no separate saga DSL.
Flow controlDeclared concurrency, throttle, debounce or replace-in-flight, admitted by the engine rather than a hand-built lock.
StorageYour database. No workflow service, no broker, no second datastore to reconcile.

Frequently asked questions

How is this different from a job queue?

A queue delivers a message and leaves the process to you: if the handler dies halfway, the whole job re-runs, so every step has to be idempotent. A durable workflow journals each step, so resumption continues rather than restarts — which is what makes multi-step processes with external side effects tractable.

Do I need Temporal, or a separate worker service?

No. The engine runs inside your app and journals to your database. There is no separate service to deploy or operate, and no second datastore whose consistency you have to reason about against your own.

What must live inside a step?

Anything non-deterministic or externally visible: network calls, database writes, random values, the current time. Resumption works by replay, so a value computed outside a step can differ on the second pass — inside one, it is journaled and replayed identically.

Can a workflow wait days for an approval?

Yes, and for that you want the suspending variant, which frees the worker slot while it waits. The framework hints once per workflow when a declared timeout is long enough that suspending is probably what you meant, rather than swapping the variant under a running history.

How do I debug a run that went wrong?

The journal is the debugger: every step, its result and its timing are recorded, and the CLI lists runs, filters for dead-lettered ones, and replays them. You read what actually happened rather than reconstructing it from log lines.

Workflows compose with the rest of the runtime.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.