temporal-ts-message-passing

star 0

Use when implementing Queries, Signals, and Updates in Temporal TypeScript Workflows. Covers defining message handlers (Query, Signal, Update with validators), sending messages from Clients and other Workflows, Signal-With-Start, Update-With-Start, async handler patterns with workflow.condition and Mutex locks, wait conditions, handler concurrency control, and troubleshooting common message-passing errors. Applies to @temporalio/workflow and @temporalio/client packages.

remodlai By remodlai schedule Updated 3/3/2026

name: temporal-ts-message-passing description: > Use when implementing Queries, Signals, and Updates in Temporal TypeScript Workflows. Covers defining message handlers (Query, Signal, Update with validators), sending messages from Clients and other Workflows, Signal-With-Start, Update-With-Start, async handler patterns with workflow.condition and Mutex locks, wait conditions, handler concurrency control, and troubleshooting common message-passing errors. Applies to @temporalio/workflow and @temporalio/client packages. version: 1.0.0

Temporal TypeScript SDK -- Workflow Message Passing

Instructions for implementing Queries, Signals, and Updates in Temporal TypeScript Workflows using @temporalio/workflow and @temporalio/client.

When to Use

Use this skill when you need to:

  • Define or register Query, Signal, or Update handlers in a Temporal TypeScript Workflow
  • Send messages (Query, Signal, Update) from a Client or another Workflow
  • Use Signal-With-Start or Update-With-Start patterns
  • Implement async handler patterns with workflow.condition, Mutex locks, or wait conditions
  • Control handler concurrency or troubleshoot message-passing errors
  • Decide which message type (Query, Signal, Update) to use for a given need
Need Use
Read Workflow state without side effects Query
Fire-and-forget state mutation Signal
State mutation that returns a result Update
Start a Workflow if not running + send a Signal Signal-With-Start
Start a Workflow if not running + send an Update Update-With-Start
Send a message from one Workflow to another External Signal (getExternalWorkflowHandle)

How to Use

Environment Context Gathering

Before implementing, confirm the following with the user:

  1. Where is Temporal running? (local dev server via temporal server start-dev, Docker Compose, Kubernetes, or Temporal Cloud)
  2. If not local, what is the full Temporal server URL? (e.g., my-namespace.abc123.tmprl.cloud:7233)
  3. Is ingress set up for the Temporal HTTP API? (needed if calling from outside the cluster or from browser clients)

Overview

Temporal Workflows act as stateful services that receive three kinds of messages:

  • Query -- synchronous read of Workflow state. Cannot mutate state. Cannot be async.
  • Signal -- asynchronous fire-and-forget message. Mutates state. Cannot return a value.
  • Update -- synchronous request that can mutate state AND return a value. Supports validators.

All three follow the same two-step pattern:

  1. Define the message type with defineQuery, defineSignal, or defineUpdate (exported global).
  2. Register a handler inside the Workflow function with wf.setHandler.

Quick Reference

API Import from Purpose
defineQuery<Ret, Args> @temporalio/workflow Declare a Query definition
defineSignal<Args> @temporalio/workflow Declare a Signal definition
defineUpdate<Ret, Args> @temporalio/workflow Declare an Update definition
setHandler(def, fn, opts?) @temporalio/workflow Register handler in Workflow
condition(fn) @temporalio/workflow Block until predicate is true
allHandlersFinished @temporalio/workflow Predicate: all handlers done
getExternalWorkflowHandle(id) @temporalio/workflow Get handle to signal another Workflow
currentUpdateInfo() @temporalio/workflow Get current Update ID and name
handle.query(def, args) @temporalio/client Send a Query
handle.signal(def, args) @temporalio/client Send a Signal
handle.executeUpdate(def, opts) @temporalio/client Send Update, wait for result
handle.startUpdate(def, opts) @temporalio/client Send Update, wait for accepted
client.workflow.signalWithStart(wf, opts) @temporalio/client Signal-With-Start
client.workflow.executeUpdateWithStart(def, opts) @temporalio/client Update-With-Start

Implementation Patterns

1. Define and Handle a Query

import * as wf from '@temporalio/workflow';

interface GetStatusInput { verbose: boolean }

// Define outside Workflow function -- export for Client use
export const getStatus = wf.defineQuery<string, [GetStatusInput]>('getStatus');

export async function myWorkflow(): Promise<void> {
  let status = 'running';

  // Query handler: MUST be sync, MUST NOT mutate state
  wf.setHandler(getStatus, (input: GetStatusInput): string => {
    return input.verbose ? `Status: ${status} (detailed)` : status;
  });

  // ... workflow logic
}

Client side:

const handle = client.workflow.getHandle('my-workflow-id');
const result = await handle.query(getStatus, { verbose: true });

2. Define and Handle a Signal

export const approve = wf.defineSignal<[{ name: string }]>('approve');

export async function myWorkflow(): Promise<string> {
  let approved = false;
  let approver: string | undefined;

  // Signal handler: CAN mutate state, CANNOT return a value
  wf.setHandler(approve, (input) => {
    approved = true;
    approver = input.name;
  });

  // Wait for the signal
  await wf.condition(() => approved);
  return `Approved by ${approver}`;
}

Client side:

await handle.signal(approve, { name: 'Alice' });

3. Define and Handle an Update (with Validator)

export const setLanguage = wf.defineUpdate<string, [string]>('setLanguage');

export async function myWorkflow(): Promise<string> {
  const supported = ['en', 'fr', 'es'];
  let language = 'en';

  wf.setHandler(
    setLanguage,
    (newLang: string) => {
      const prev = language;
      language = newLang;
      return prev; // Update CAN return a value
    },
    {
      // Validator: sync, same args, returns void, throw to reject
      validator: (newLang: string) => {
        if (!supported.includes(newLang)) {
          throw new Error(`${newLang} is not supported`);
        }
      },
    }
  );

  // ... workflow logic
}

Client side -- wait for completion:

const prev = await handle.executeUpdate(setLanguage, { args: ['fr'] });

Client side -- wait for acceptance only:

import { WorkflowUpdateStage } from '@temporalio/client';

const updateHandle = await handle.startUpdate(setLanguage, {
  args: ['fr'],
  waitForStage: WorkflowUpdateStage.ACCEPTED,
});
const prev = await updateHandle.result(); // await completion later

4. Signal from Another Workflow (External Signal)

import { getExternalWorkflowHandle } from '@temporalio/workflow';
import { joinSignal } from './other-workflow';

export async function senderWorkflow() {
  const handle = getExternalWorkflowHandle('target-workflow-id');
  await handle.signal(joinSignal, { userId: 'user-1' });
}

5. Signal-With-Start (Client Only)

import { Client } from '@temporalio/client';
import { joinSignal, myWorkflow } from './workflows';

const client = new Client();
await client.workflow.signalWithStart(myWorkflow, {
  workflowId: 'wf-123',
  taskQueue: 'my-queue',
  args: [{ foo: 1 }],
  signal: joinSignal,
  signalArgs: [{ userId: 'user-1', groupId: 'group-1' }],
});

6. Update-With-Start (Client Only)

Requires Temporal Server >= 1.28.

import { WithStartWorkflowOperation } from '@temporalio/client';

const startOp = WithStartWorkflowOperation.create(myWorkflow, {
  workflowId: 'wf-123',
  args: [txnId],
  taskQueue: 'my-queue',
  workflowIdConflictPolicy: 'FAIL',
});

const earlyResult = await client.workflow.executeUpdateWithStart(
  getConfirmation,
  { startWorkflowOperation: startOp }
);

const wfHandle = await startOp.workflowHandle();
const finalResult = await wfHandle.result();

7. Async Handler with Activity Execution

export const processItem = wf.defineUpdate<string, [string]>('processItem');

export async function myWorkflow(): Promise<void> {
  wf.setHandler(processItem, async (itemId: string) => {
    // Async handlers can call Activities, Child Workflows, sleep, condition
    const result = await wf.executeActivity(processItemActivity, {
      args: [itemId],
      startToCloseTimeout: '30s',
    });
    return result;
  });

  await wf.condition(wf.allHandlersFinished);
}

8. Wait for Handlers to Finish Before Workflow Completes

export async function myWorkflow(): Promise<string> {
  // ... set up handlers ...

  // CRITICAL: wait for all async handlers to complete
  await wf.condition(wf.allHandlersFinished);
  return 'done';
}

9. Mutex Lock for Handler Concurrency Control

import { Mutex } from 'async-mutex';

export async function myWorkflow(): Promise<void> {
  let x = 0;
  let y = 0;
  const lock = new Mutex();

  wf.setHandler(mySignal, async () => {
    await lock.runExclusive(async () => {
      const data = await myActivity();
      x = data.x;
      // Safe: no other handler instance runs this section concurrently
      await wf.sleep(500);
      y = data.y;
    });
  });
}

Install async-mutex in your Workflow bundle: npm install async-mutex

10. Wait Condition in Handler (Gate Pattern)

let initialized = false;

wf.setHandler(myUpdate, async (input): Promise<string> => {
  // Block handler until Workflow initialization is complete
  await wf.condition(() => initialized);
  // Now safe to proceed
  return `processed: ${input}`;
});

// In main Workflow body:
await doInitialization();
initialized = true; // unblocks any waiting handlers

11. Dynamic Signal Handlers

// Option A: Fat handler with payload routing
wf.setHandler(wf.defineSignal('genericSignal'), (payload) => {
  switch (payload.taskId) {
    case 'taskA': /* handle A */ break;
    case 'taskB': /* handle B */ break;
  }
});

// Option B: Inline definitions with dynamic names
wf.setHandler(wf.defineSignal(`task-${taskAId}`), (payload) => {
  /* handle task A */
});

12. Untyped / Dynamic Client Calls

When you cannot import the message definitions (e.g., cross-language):

// Pass string names instead of definition objects
const result = await handle.query('getStatus', { verbose: true });
await handle.signal('approve', { name: 'Alice' });
const prev = await handle.executeUpdate('setLanguage', { args: ['fr'] });

Common Mistakes

Mistake Why it fails Fix
Making a Query handler async Queries cannot perform async ops Remove async, return synchronously
Returning a value from a Signal handler Signal handlers ignore return values Use an Update if you need a return value
Mutating state in a Query handler Breaks determinism guarantees Queries must be pure reads
Not waiting for handlers before Workflow exit Handlers get interrupted, clients get errors Add await wf.condition(wf.allHandlersFinished)
Concurrent async handlers corrupting state Interleaving at await points Use Mutex from async-mutex
Using Continue-as-New inside an Update handler Not supported by Temporal Call Continue-as-New only from the main Workflow method
Calling Temporal Client directly in Workflow code Breaks determinism Use getExternalWorkflowHandle for Workflow-to-Workflow signals
Async Update validator Validators must be sync Remove async from the validator function

Debugging Tips

  1. No handler registered: If you see QueryNotRegisteredError, the handler name doesn't match or setHandler was never called. Verify the string name matches between defineQuery/defineSignal/defineUpdate and the Client call.

  2. Update times out / hangs: Ensure a Worker is polling the correct Task Queue. Check with temporal task-queue describe --task-queue <name>.

  3. WorkflowUpdateFailedError: Either the validator rejected the Update (no event in history) or the handler threw after acceptance (events written). Check Workflow history in the Temporal UI.

  4. gRPC UNAVAILABLE (code 14): Client cannot reach the Temporal server. Verify the server address, network connectivity, and TLS settings.

  5. gRPC FAILED_PRECONDITION (code 9): For Queries, no Worker is polling. For Updates, the request was not yet accepted and the Workflow Task failed. Deploy a fix and retry.

  6. gRPC NOT_FOUND (code 5): The Workflow finished while an Update handler was still running. Add await wf.condition(wf.allHandlersFinished) before returning.

  7. Worker warnings about unfinished handlers: Set unfinishedPolicy in handler options to silence per-handler, or add the allHandlersFinished wait condition.

  8. Nondeterminism errors after adding a handler: New handlers are safe to add (they are side-effect-free registrations). If you changed handler logic that already executed, that breaks replay. Use versioning.

  9. Signal delivered but handler not called: Signals are buffered. If the handler is registered after the Signal arrives, it will fire when setHandler is called. Ensure setHandler runs early in the Workflow function.

  10. Use currentUpdateInfo() inside an Update handler to get the Update ID for logging or deduplication during Continue-as-New.

Further Reading

If this skill doesn't answer your question, use Context7 to search /temporalio/sdk-typescript or /temporalio/samples-typescript for more details. Particularly useful sample directories:

  • message-passing/introduction -- basic Query, Signal, Update examples
  • message-passing/safe-message-handlers -- async handler patterns with Mutex and allHandlersFinished
  • signals-queries -- static Signal and Query definitions
  • early-return -- Update-With-Start pattern
Install via CLI
npx skills add https://github.com/remodlai/skills-temporal --skill temporal-ts-message-passing
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator