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, delay } from '@justscale/process'
import { OrderService, ShippingService } from './services'

export const OrderFulfillment = createProcess({
  // Path pattern with parameters (like routes)
  path: '/order/:orderId/fulfillment',

  // Dependencies injected via DI
  inject: {
    orders: OrderService,
    shipping: ShippingService,
  },

  // Handler receives deps and path params
  async handler({ orders, shipping }, [orderId]) {
    // Fetch order - 'using' ensures it's re-fetched on resume
    using order = await orders.findById(orderId)

    // SUSPENSION POINT: Wait for payment signal
    const payment = await signal(orders.paymentReceived)

    // Continue after payment received
    await shipping.dispatch(orderId, payment.address)

    // SUSPENSION POINT: Wait for shipment or timeout
    await signal(shipping.delivered)

    return { status: 'completed', orderId }
  },
})

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

Create signals using createSignal with identity parameters (for routing) and an optional payload type:

payment-service.tsTypeScript
import { createService } from '@justscale/core'
import { createSignal } from '@justscale/process'

export const PaymentService = createService({
  inject: {},
  factory: () => ({
    // Signal with payload - caller must provide orderId + payload
    received: createSignal<[orderId: string], { txId: string; amount: number }>(
      'payments.received',
      ['orderId']
    ),

    // Signal without payload
    failed: createSignal<[orderId: string]>(
      'payments.failed',
      ['orderId']
    ),

    // Signal with multiple identity params
    refunded: createSignal<[orderId: string, userId: string], { reason: string }>(
      'payments.refunded',
      ['orderId', 'userId']
    ),
  }),
})

Waiting for Signals

Inside a process handler, use signal() to wait for a signal. The process suspends until the signal is emitted, then continues with the payload:

checkout-process.tsTypeScript
import { createProcess, signal } from '@justscale/process'
import { PaymentService, NotificationService } from './services'

export const CheckoutProcess = createProcess({
  path: '/checkout/:orderId',
  inject: { payments: PaymentService, notifications: NotificationService },

  async handler({ payments, notifications }, [orderId]) {
    // Process suspends here until payments.received is emitted
    const payment = await signal(payments.received)

    // payment is typed as { txId: string; amount: number }
    await notifications.send(`Payment ${payment.txId} for $${payment.amount}`)

    return { success: true, txId: payment.txId }
  },
})

Emitting Signals

Emit signals from outside processes - typically from webhook handlers, API endpoints, or background jobs. Call the signal like a function with identity params and payload:

webhook-controller.tsTypeScript
import { createController } from '@justscale/core'
import { Post } from '@justscale/http'
import { PaymentService } from './services'

export const WebhookController = createController('/webhooks', {
  inject: { payments: PaymentService },

  routes: ({ payments }) => ({
    stripePayment: Post('/stripe/payment')
      .handle(async ({ body, res }) => {
        const { orderId, transactionId, amount } = body

        // Emit signal - resumes any process waiting for this
        await payments.received(orderId, { txId: transactionId, 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/process'
import { PaymentService, OrderService } from './services'

export const PaymentWithTimeout = createProcess({
  path: '/payment/:orderId/wait',
  inject: { payments: PaymentService, orders: OrderService },

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

    switch (true) {
      case signal(r, payments.received):
        // r is narrowed to { txId: string; amount: number }
        return { status: 'paid', txId: r.txId, amount: r.amount }

      case signal(r, orders.cancelled):
        // r is narrowed to void (no payload)
        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/process'
import { AuthService, EmailService } from './services'

export const EmailVerification = createProcess({
  path: '/verify/:userId/email',
  inject: { auth: AuthService, email: EmailService },

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

    // Send initial verification email
    await email.sendVerificationCode(userId)

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

      switch (true) {
        case signal(r, auth.codeSubmitted):
          // r is { code: string }
          const valid = await auth.verifyCode(userId, r.code)
          return { verified: valid, attempts: resendCount + 1 }

        case signal(r, auth.resendRequested):
          resendCount++
          await email.sendVerificationCode(userId)
          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/process'
import { OrderService } from './services'

export const OrderProcess = createProcess({
  path: '/order/:orderId/process',
  inject: { orders: OrderService },

  async handler({ orders }, [orderId]) {
    // 'using' means: re-execute this expression on resume
    using order = await orders.findById(orderId)

    // order is fresh after every suspension point
    if (order.status === 'cancelled') {
      return { success: false, reason: 'order_cancelled' }
    }

    // Suspend here...
    await signal(orders.paymentReceived)

    // After resume, 'order' is re-fetched automatically
    // so we see the latest state
    return { success: true, finalStatus: order.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 { 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
        const handle = await OrderFulfillment([order.id])

        res.json({
          orderId: order.id,
          processId: handle.id,
          status: handle.status, // 'pending' | 'running' | 'suspended' | 'completed' | 'failed'
        })
      }),
  }),
})

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
const handle1 = await OrderFulfillment(['order-123'])
console.log(handle1.id) // 'order/order-123/fulfillment'

// Second call returns handle to same process
const handle2 = await OrderFulfillment(['order-123'])
console.log(handle2.id) // 'order/order-123/fulfillment' (same!)

// Wait for completion
const result = await handle2.wait()
console.log(result) // { status: 'completed', orderId: 'order-123' }

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