Systemics
← All blog posts & case studies
Article | Architecture |

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

SignalQueryUpdate
DirectionClient to workflowClient to workflowClient to workflow
Returns a valueNoYes (read-only)Yes (after validation)
Mutates stateYesNeverYes
DeliveryAt-least-once, asyncSync read of replayed stateSync, two-phase (accept, then complete)
Blocks the workflowNoNoCan 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'],
  },
});
OptionDefaultNotes
initialInterval1sFirst backoff delay
backoffCoefficient2Exponential multiplier
maximumInterval100x initialBackoff cap
maximumAttemptsunlimitedSet explicit caps on non-idempotent work
nonRetryableErrorTypesnoneFail 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 policyEffect when parent closes
TERMINATE (default)Children are terminated
ABANDONChildren keep running
REQUEST_CANCELChildren 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.

Book a free assessment: systemics.ai.

Start with a free architecture assessment.

Thirty minutes, no slides. Tell us where your AI posture stands and we will tell you honestly whether we can help.