Skip to content

Durable Processes

Long-running workflows that survive restarts

Durable processes are async workflows that can suspend, persist their state to storage, and resume later - even after server restarts. They're ideal for multi-step business flows like order fulfillment, payment processing, or user onboarding.

Why Durable Processes?

Traditional async functions lose their state when the server restarts. A payment flow that waits for webhook confirmation would fail if the server restarts during the wait. Durable processes solve this by:

  • Persisting state - Variables are saved to storage at suspension points
  • Signal-based resumption - External events (webhooks, user actions) resume the flow
  • Durable timers - Delays survive restarts and fire at the correct time
  • Compile-time transformation - Your async code is transformed into a resumable state machine

Creating a Process

Use createProcess to define a durable process with a path pattern, dependencies, and handler function. The handler looks like regular async code but can suspend at specific points:

order-fulfillment.tsTypeScript
import { createProcess, signal, race, delay } from '@justscale/core/process'
import { Order } from './models'
import { OrderSignals, ShippingSignals } from './signals'

export const OrderFulfillment = createProcess({
  // Path pattern with parameters — param names match model keys in .types()
  path: '/order/:order/fulfillment',
  types: { Order },   // :order → Ref<Order> in the handler

  // Dependencies injected via DI
  inject: {
    orders: OrderSignals,
    shipping: ShippingSignals,
  },

  // Handler receives deps and typed path params
  async handler({ orders, shipping }, { order }) {
    // 'order' is Ref<Order>. Resolve it — used to read details, not mutate.
    const found = await order
    if (!found) return { status: 'failed', reason: 'Order not found' }

    const r = race()
    switch (true) {
      // SUSPENSION POINT: payment with 3-day timeout
      case signal(r, orders.paymentConfirmed):
        // r.order is Locked<Order>, r.txId is string (from the signal's .data)
        await shipping.dispatch(r.order, r.address)
        break
      case delay.days(r, 3):
        return { status: 'payment_timeout', order: found }
    }

    // SUSPENSION POINT: wait for delivery
    await signal(shipping.delivered)

    return { status: 'completed', order: found }
  },
})

Signals

Signals are the core suspension mechanism. They represent events that can be emitted from outside a process and awaited inside. When a process awaits a signal, it suspends and persists its state until the signal is emitted.

Defining Signals

Signal groups are declared with defineSignals. Each call to signal('/path') produces a routable, emit-able signal — path params matched by .types({ Model }) carry Locked<T>, and .data<T>() adds any extra payload fields.

payment-signals.tsTypeScript
import { defineSignals } from '@justscale/core/process'
import { Order, User } from './models'

export class PaymentSignals extends defineSignals(signal => ({
  // Emit: { order: Locked<Order>, txId: string, amount: number }
  received: signal('/payment/:order/received')
    .data<{ txId: string; amount: number }>()
    .types({ Order }),

  // No extra data — just the Locked entity
  failed: signal('/payment/:order/failed')
    .types({ Order }),

  // Multiple typed path params
  refunded: signal('/payment/:order/user/:user/refunded')
    .data<{ reason: string }>()
    .types({ Order, User }),
})) {}

Waiting for Signals

Inside a process handler, signal(racer, target) inside a switch(true) narrows the race result to the signal's payload. Without a racer, await signal(target) suspends until it fires.

checkout-process.tsTypeScript
import { createProcess, signal, race } from '@justscale/core/process'
import { Order } from './models'
import { PaymentSignals } from './signals'

export const CheckoutProcess = createProcess({
  path: '/checkout/:order',
  types: { Order },
  inject: { payments: PaymentSignals },

  async handler({ payments }, { order }) {
    const r = race()
    switch (true) {
      case signal(r, payments.received):
        // r is narrowed: r.order → Locked<Order>, r.txId, r.amount
        return { success: true, txId: r.txId, amount: r.amount }

      case signal(r, payments.failed):
        // r.order → Locked<Order>
        return { success: false, order: r.order }
    }
  },
})

Emitting Signals

Emit from outside a process — webhook handlers, API endpoints, background jobs. Call the signal with a single object: typed path params take Locked<T>entities (the emitter holds the lock), plus any declared data fields.

webhook-controller.tsTypeScript
import { createController, ModelRepository } from '@justscale/core'
import { Post } from '@justscale/http'
import { Order } from './models'
import { PaymentSignals } from './signals'

export const WebhookController = createController('/webhooks', {
  inject: {
    payments: PaymentSignals,
    orders: ModelRepository.of(Order),
  },

  routes: ({ payments, orders }) => ({
    stripePayment: Post('/stripe/payment')
      .handle(async ({ body, res }) => {
        // Lock the order before emitting — receivers get a valid Locked<Order>
        using order = await orders.lock(Order.ref(body.orderId))
        if (!order) return res.status(404).end()

        await payments.received({
          order,
          txId: body.transactionId,
          amount: body.amount,
        })

        res.json({ received: true })
      }),
  }),
})

Racing Signals

Use race() to wait for the first of multiple signals or timers. The pattern usesswitch(true) with type guards for type-safe narrowing:

payment-with-timeout.tsTypeScript
import { createProcess, signal, race, delay } from '@justscale/core/process'
import { Order } from './models'
import { PaymentSignals, OrderSignals } from './signals'

export const PaymentWithTimeout = createProcess({
  path: '/payment/:order/wait',
  types: { Order },
  inject: { payments: PaymentSignals, orders: OrderSignals },

  async handler({ payments, orders }, { order }) {
    // Race between payment, cancellation, and timeout
    const r = race()

    switch (true) {
      case signal(r, payments.received):
        // r.order → Locked<Order>, r.txId → string, r.amount → number
        return { status: 'paid', txId: r.txId, amount: r.amount }

      case signal(r, orders.cancelled):
        // r.order → Locked<Order> (no extra data on this signal)
        return { status: 'cancelled' }

      case delay.minutes(r, 30):
        // Timeout - 30 minute timer
        return { status: 'expired' }
    }
  },
})

Racing in Loops

Combine race patterns with loops for flows that need retry or resend logic:

verification-with-resend.tsTypeScript
import { createProcess, signal, race, delay } from '@justscale/core/process'
import { User } from './models'
import { AuthSignals, EmailService } from './services'

export const EmailVerification = createProcess({
  path: '/verify/:user/email',
  types: { User },
  inject: { auth: AuthSignals, email: EmailService },

  async handler({ auth, email }, { user }) {
    let resendCount = 0
    const maxResends = 3

    // Send initial verification email (services take Ref<T> — no raw IDs)
    await email.sendVerificationCode(user)

    while (resendCount < maxResends) {
      const r = race()

      switch (true) {
        case signal(r, auth.codeSubmitted):
          // r.user → Locked<User>, r.code → string
          const valid = await auth.verifyCode(r.user, r.code)
          return { verified: valid, attempts: resendCount + 1 }

        case signal(r, auth.resendRequested):
          resendCount++
          await email.sendVerificationCode(user)
          continue // Loop back

        case delay.minutes(r, 10):
          return { verified: false, reason: 'timeout' }
      }
    }

    return { verified: false, reason: 'max_resends_exceeded' }
  },
})

The 'using' Keyword

Non-serializable values (like database entities with methods, connections, etc.) can't be persisted. Use the using keyword to mark values that should be re-fetched when the process resumes:

order-process.tsTypeScript
import { createProcess, signal } from '@justscale/core/process'
import { ModelRepository } from '@justscale/core/models'
import { Order } from './models'
import { PaymentSignals } from './signals'

export const OrderProcess = createProcess({
  path: '/order/:order/process',
  types: { Order },
  inject: {
    orders: ModelRepository.of(Order),
    payments: PaymentSignals,
  },

  async handler({ orders, payments }, { order }) {
    // 'using' means: re-execute this expression on resume
    using current = await orders.lock(order)
    if (!current) return { success: false, reason: 'order_missing' }
    if (current.status === 'cancelled') {
      return { success: false, reason: 'order_cancelled' }
    }

    // Suspend here — lock is released before suspension (using block ends)
    await signal(payments.received)

    // After resume, 'current' is re-fetched and re-locked automatically
    // so we see (and can mutate) the latest state.
    return { success: true, finalStatus: current.status }
  },
})

Under the hood, the compiler creates "rehydration blocks" that re-execute the using expressions when the process resumes from a suspension point.

Starting Processes

Process definitions are callable - pass the path parameters as a tuple to start an instance:

order-controller.tsTypeScript
import { createController } from '@justscale/core'
import { Post } from '@justscale/http'
import { Order } from './models'
import { OrderFulfillment } from './processes'
import { OrderService } from './services'

export const OrderController = createController('/orders', {
  inject: { orders: OrderService },

  routes: ({ orders }) => ({
    create: Post('/')
      .handle(async ({ body, res }) => {
        const order = await orders.create(body)

        // Start the fulfillment process with a typed ref — no raw IDs
        const handle = await OrderFulfillment([Order.ref(order)])

        res.json({
          order: Order.ref(order).identifier, // only at system boundaries
          processId: handle.id,
          status: handle.status,
        })
      }),
  }),
})

Idempotent Starts

Starting a process is idempotent - if a process with those parameters already exists, you get a handle to the existing one instead of creating a duplicate:

idempotent-start.tsTypeScript
// First call creates the process — pass a Ref, not a raw string
const handle1 = await OrderFulfillment([Order.ref('order-123')])
console.log(handle1.id) // 'order/order-123/fulfillment'

// Second call with the same ref returns a handle to the same process
const handle2 = await OrderFulfillment([Order.ref('order-123')])
console.log(handle2.id === handle1.id) // true

// Wait for completion
const result = await handle2.wait()
console.log(result) // { status: 'completed', order: Persistent<Order> }

How It Works

The JustScale compiler transforms your async handler into a switch-based state machine. This happens at build time, so there's no runtime overhead for parsing your code.

Compilation Pipeline

  • Analysis - The compiler scans your handler for suspension points (signal(), delay(), race())
  • Step extraction - Each suspension point becomes a separate case in the switch
  • Variable rewriting - Variables that cross suspensions become state.vars.xxx lookups
  • Rehydration insertion - using declarations are re-fetched at each step

Runtime Execution

At runtime, the executor manages the state machine:

  • Case 0 - Entry point, runs until first suspension
  • Case 1, 2, ... - Resume points after each signal/timer
  • Return [0, result] - Process completed with result
  • Return [1, {race: branches}] - Process suspended, waiting for signals

When a signal is emitted, the executor sets the next step and re-runs the execute function from where it left off.

Limitations

Some patterns are not supported in process handlers because they can't be safely suspended:

  • try/catch around suspensions - Can't serialize exception state
  • for-of loops with suspensions - Iterator state isn't serializable
  • Promise.all with signals - Use race() instead
  • Nested async functions with signals - Keep suspension points in the main handler
  • Storing signal() in variables - Must be awaited directly

The compiler detects these patterns and provides helpful error messages.

Best Practices

  • Use signals for external events - Webhooks, user actions, scheduled events
  • Keep handlers focused - One process per business flow, delegate to services
  • Use 'using' for entities - Ensures fresh data after suspension
  • Design for idempotency - Process paths should uniquely identify an instance
  • Add timeouts - Use race() with delay() to prevent stuck processes
  • Test with TestContainer - Mock the signal bus and timer scheduler for unit tests