Skip to content
All resources
Cheat sheet

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.