Temporal Workflow Patterns: Signals, Queries, Updates, and the Rest
The reference card I keep open when writing Temporal workflows in TypeScript: signals vs queries vs updates, retries, heartbeats, child workflows, schedules, versioning.
Signals vs queries vs updates
| Signal | Query | Update | |
|---|---|---|---|
| Direction | Client to workflow | Client to workflow | Client to workflow |
| Returns a value | No | Yes (read-only) | Yes (after validation) |
| Mutates state | Yes | Never | Yes |
| Delivery | At-least-once, async | Sync read of replayed state | Sync, two-phase (accept, then complete) |
| Blocks the workflow | No | No | Can await conditions inside |
| Use for | "Something happened" (approval, cancel) | "What is the state" | "Do this and tell me the result" |
Rules: queries must have no side effects and no external awaits; they run against replayed history. Signals can arrive before their handler is registered; never assume ordering. Updates can reject in the validator before acceptance, which is the right place for permission checks.
Definitions and handlers
import { defineSignal, defineQuery, defineUpdate, setHandler, condition } from '@temporalio/workflow';
export const approveSignal = defineSignal<[string]>('approve');
export const statusQuery = defineQuery<string>('status');
export const renameUpdate = defineUpdate<string, [string]>('rename');
export async function approvalWorkflow(): Promise<string> {
let status = 'pending';
setHandler(approveSignal, (by: string) => {
status = `approved by ${by}`;
});
setHandler(statusQuery, () => status);
setHandler(renameUpdate, (name: string) => {
status = `renamed to ${name}`;
return status;
}, {
validator: (name: string) => {
if (!name.trim()) throw new Error('name required');
},
});
await condition(() => status.startsWith('approved'), '24h');
return status;
}
Activity retries
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const { chargeCard } = proxyActivities<typeof activities>({
startToCloseTimeout: '5 minutes',
retry: {
initialInterval: '1s',
backoffCoefficient: 2,
maximumInterval: '1 minute',
maximumAttempts: 5,
nonRetryableErrorTypes: ['CardDeclined'],
},
});
| Option | Default | Notes |
|---|---|---|
initialInterval |
1s | First backoff delay |
backoffCoefficient |
2 | Exponential multiplier |
maximumInterval |
100x initial | Backoff cap |
maximumAttempts |
unlimited | Set explicit caps on non-idempotent work |
nonRetryableErrorTypes |
none | Fail fast on permanent errors |
Timeouts nest: scheduleToClose (whole activity across retries) > startToClose (one attempt) > heartbeat (liveness within an attempt). Always set startToClose. Every side-effecting activity needs an idempotency key, because a retry after partial success is normal operation, not an edge case.
Heartbeats
Use for anything longer than ~30 seconds or anything resumable.
import { heartbeat } from '@temporalio/activity';
export async function backfill(total: number): Promise<void> {
for (let done = 0; done < total; done += 100) {
await processBatch(done, 100);
heartbeat(done + 100); // progress survives a retry
}
}
Workflow side: set heartbeatTimeout: '30s' in the activity options. On retry, read the last heartbeat details from the activity failure to resume from progress. A worker killed mid-activity without heartbeats looks alive until startToClose expires.
Child workflows
import { startChild, ParentClosePolicy } from '@temporalio/workflow';
const child = await startChild(processOrder, {
workflowId: `order-${orderId}`,
taskQueue: 'orders',
parentClosePolicy: ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON,
});
| Close policy | Effect when parent closes |
|---|---|
TERMINATE (default) |
Children are terminated |
ABANDON |
Children keep running |
REQUEST_CANCEL |
Children get a cancellation request |
Prefer children over one giant workflow when units of work are independent, need their own retry boundaries, or outlive the parent. Avoid fan-out beyond a few thousand children per parent; history size and replay time grow. Long-running entities (an account, a subscription) belong in their own workflow that parents signal, not in child trees.
Schedules
Server-owned cron with overlap control and catchup windows. Replaces CronJobs that shell out to a workflow starter.
await client.schedule.create({
scheduleId: 'nightly-rollup',
spec: { calendars: [{ hour: 2, minute: 0 }] },
policies: {
overlap: ScheduleOverlapPolicy.SKIP,
catchupWindow: '1 hour',
},
action: {
type: 'startWorkflow',
workflowType: nightlyRollup,
taskQueue: 'reports',
args: [],
},
});
Overlap: SKIP for reports, BUFFER_ONE when runs must not be lost, avoid ALLOW_ALL for batch. Always set a catchup window; after an outage the default replays every missed run at once.
Versioning
Workflow histories replay against your code. Any change to command order breaks in-flight executions unless you version.
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
taskQueue: 'billing',
buildId: process.env.IMAGE_TAG, // one per deploy
useVersioning: true,
});
- Pin executions to the build that started them; new executions take the new build.
- Drain the old build before deleting it: watch pinned execution counts, not pod counts.
- For emergency fixes on unversioned workflows, use the patch API and keep the patch branch until all histories pass it.
Quick rules
- Workflow code must be deterministic: no clocks, randomness, or I/O outside activities and SDK built-ins.
- Everything flaky or external is an activity. Everything durable is workflow state.
- Signals for events, queries for reads, updates when the caller needs a verdict.
- Long-running entity workflows continue-as-new on a history-size budget, not on a calendar.