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, 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.
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.
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.
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:
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:
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:
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:
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:
// 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.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