Durable Workflows (Temporal)
Wiring Temporal is not a single client init — it's a small architecture with two moving parts that share one task queue name:
app (client) ──start──▶ Temporal server ──dispatch──▶ worker process
(task queue "X") (runs workflows + activities)
- The client lives in the app (route handlers, job triggers). It starts and signals workflows. It never runs workflow code.
- The worker is a separate long-running process. It registers your workflows and activities and polls the task queue.
kilter provisions the server and provides two env vars — confirm with kilter env, never hardcode:
TEMPORAL_ADDRESS— gRPC frontend (:7233); client and worker both connect here.TEMPORAL_UI_URL— the Web UI (:8233) for inspecting executions.
Setup
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activityClient — enqueue from the app:
const connection = await Connection.connect({ address: process.env.TEMPORAL_ADDRESS });
const client = new Client({ connection });
await client.workflow.start(orderWorkflow, {
taskQueue: 'orders', // MUST match the worker exactly
workflowId: `order-${orderId}`, // dedup key — reused id = same workflow
args: [orderId],
});Worker — its own entry point:
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'orders', // MUST match the client
});
await worker.run();Go (go.temporal.io/sdk) and Python (temporalio) use the same client/worker/task-queue model.
The determinism rule
Temporal replays workflow code on every state transition, so workflows must be pure orchestration: no Date.now(), Math.random(), fetch, DB calls, or reading env at runtime. Use workflow APIs instead (sleep, condition, proxyActivities). All side effects — DB writes, HTTP calls, email — go in activities, which are retried independently and never replayed.
// workflows.ts — deterministic orchestration only
const { chargeCard, sendReceipt } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
});
export async function orderWorkflow(orderId: string) {
await chargeCard(orderId); // side effect → activity
await sleep('1 day'); // durable timer — survives restarts
await sendReceipt(orderId);
}Putting a fetch or DB call directly in a workflow. It appears to work — until a replay produces a DeterminismViolation. If you see that error, move the side effect into an activity; for incompatible code changes to running workflows, version with patched().
Deploying the worker
Declare it under processes: in kilter.yaml. Worker processes share the app image and inherit its full env bag, so TEMPORAL_ADDRESS is already present:
processes:
worker:
command: [node, dist/temporal/worker.js]kilter up starts it alongside the app; kilter deploy ships it as its own workload.
Verify
kilter up(app + worker), trigger a workflow through the client path.- Open
TEMPORAL_UI_URL— the execution should appear and complete.
| Symptom | Cause / fix |
|---|---|
Workflow stuck in Running | No worker polling that queue — queue-name mismatch, or worker not running (kilter status) |
| Worker starts, does nothing | Workflows/activities not registered, or wrong TEMPORAL_ADDRESS |
| Activity retries forever | No retry cap — set retry.maximumAttempts in proxyActivities |
| Can't reach the server | Hardcoded/stale address — read it from kilter env |