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:
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:
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:
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:
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:
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:
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:
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:
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:
// 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.xxxlookups - Rehydration insertion -
usingdeclarations 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()withdelay()to prevent stuck processes - Test with TestContainer - Mock the signal bus and timer scheduler for unit tests