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.
Instructions for implementing Queries, Signals, and Updates in Temporal TypeScript Workflows using @temporalio/workflow and @temporalio/client.
Use this skill when you need to:
workflow.condition, Mutex locks, or wait conditions| 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) |
Before implementing, confirm the following with the user:
temporal server start-dev, Docker Compose, Kubernetes, or Temporal Cloud)my-namespace.abc123.tmprl.cloud:7233)Temporal Workflows act as stateful services that receive three kinds of messages:
All three follow the same two-step pattern:
defineQuery, defineSignal, or defineUpdate (exported global).wf.setHandler.| 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 |
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 });
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' });
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
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' });
}
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' }],
});
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();
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);
}
export async function myWorkflow(): Promise<string> {
// ... set up handlers ...
// CRITICAL: wait for all async handlers to complete
await wf.condition(wf.allHandlersFinished);
return 'done';
}
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
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
// 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 */
});
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'] });
| 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 |
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.
Update times out / hangs: Ensure a Worker is polling the correct Task Queue. Check with temporal task-queue describe --task-queue <name>.
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.
gRPC UNAVAILABLE (code 14): Client cannot reach the Temporal server. Verify the server address, network connectivity, and TLS settings.
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.
gRPC NOT_FOUND (code 5): The Workflow finished while an Update handler was still running. Add await wf.condition(wf.allHandlersFinished) before returning.
Worker warnings about unfinished handlers: Set unfinishedPolicy in handler options to silence per-handler, or add the allHandlersFinished wait condition.
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.
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.
Use currentUpdateInfo() inside an Update handler to get the Update ID for logging or deduplication during Continue-as-New.
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 examplesmessage-passing/safe-message-handlers -- async handler patterns with Mutex and allHandlersFinishedsignals-queries -- static Signal and Query definitionsearly-return -- Update-With-Start pattern