Use when implementing long-running entity workflows in Temporal TypeScript SDK, including the single-entity pattern with continueAsNew, signal/update handling, query handlers, cancellation scopes, and workflow lifecycle management. Covers defining signals, queries, and updates with setHandler, using condition() for event-driven loops, and properly draining pending updates before continueAsNew.
Instructions for implementing long-running entity workflows in Temporal TypeScript SDK using the single-entity pattern with signals, queries, updates, continueAsNew, and cancellation handling.
Use this skill when you need to:
condition() and continueAsNewcontinueAsNew| Use Case | Entity Pattern? |
|---|---|
| Model a long-lived entity (user, order, device) | Yes |
| Need to receive updates over days/weeks/months | Yes |
| Need queryable state without a database |
| Yes |
| Short-lived request-response (< 1 min) | No, use a simple workflow |
| Batch processing of many items | No, use child workflows |
| Pure scheduling / cron | No, use schedules |
Before implementing, clarify the following with the user:
temporal server start-dev), self-hosted cluster, or Temporal Cloud?my-namespace.abc123.tmprl.cloud:7233)"default" for local dev.These affect the Client and Worker connection configuration.
The entity pattern models a single long-lived entity (user, order, device, subscription) as one Temporal Workflow Execution. The workflow:
continueAsNew to reset event history and avoid hitting the 50K event limitThe workflow ID typically maps 1:1 to the entity ID (e.g., user-abc123).
| Concept | API | Import From |
|---|---|---|
| Define a signal | defineSignal<[ArgType]>('name') | @temporalio/workflow |
| Define a query | defineQuery<ReturnType>('name') | @temporalio/workflow |
| Define an update | defineUpdate<ReturnType, [ArgType]>('name') | @temporalio/workflow |
| Register handler | setHandler(definition, handlerFn, options?) | @temporalio/workflow |
| Wait for condition | condition(() => bool, timeout?) | @temporalio/workflow |
| Continue as new | continueAsNew<typeof myWorkflow>(args) | @temporalio/workflow |
| Check cancellation | isCancellation(err) | @temporalio/workflow |
| Non-cancellable scope | CancellationScope.nonCancellable(async () => {}) | @temporalio/workflow |
| Proxy activities | proxyActivities<typeof acts>(options) | @temporalio/workflow |
| Sleep | sleep('1 hour') or sleep(60000) | @temporalio/workflow |
This is the canonical pattern. It collects updates via a signal, processes them, and calls continueAsNew after a fixed number of iterations.
import {
defineSignal,
setHandler,
condition,
continueAsNew,
isCancellation,
CancellationScope,
proxyActivities,
} from '@temporalio/workflow';
import type * as activities from './activities';
const { processUpdate, setup, cleanup } = proxyActivities<typeof activities>({
startToCloseTimeout: '5 minutes',
});
// --- Type definitions ---
interface EntityInput {
entityId: string;
config: Record<string, unknown>;
}
interface EntityUpdate {
action: string;
payload: unknown;
}
// --- Signal definition (exported so clients can import it) ---
export const updateSignal = defineSignal<[EntityUpdate]>('update');
const MAX_ITERATIONS = 1;
export async function entityWorkflow(
input: EntityInput,
isNew = true,
): Promise<void> {
try {
const pendingUpdates: EntityUpdate[] = [];
setHandler(updateSignal, (updateCommand) => {
pendingUpdates.push(updateCommand);
});
if (isNew) {
await setup(input);
}
for (let iteration = 1; iteration <= MAX_ITERATIONS; ++iteration) {
// Wait up to 1 day for updates; ensures continueAsNew eventually fires
await condition(() => pendingUpdates.length > 0, '1 day');
// Drain all pending updates
while (pendingUpdates.length) {
const update = pendingUpdates.shift()!;
await processUpdate(update);
}
}
} catch (err) {
if (isCancellation(err)) {
await CancellationScope.nonCancellable(async () => {
await cleanup();
});
}
throw err;
}
// Continue as new, passing isNew=false to skip setup
await continueAsNew<typeof entityWorkflow>(input, false);
}
Key points:
MAX_ITERATIONS controls how many update cycles before continueAsNew. Set to 1 for frequent resets, higher for fewer.isNew flag differentiates first run from continued runs (skip setup on continue).condition() with a timeout ensures the workflow does not block forever.while loop before continuing.A richer entity that exposes queryable state and supports validated updates.
import {
defineSignal,
defineQuery,
defineUpdate,
setHandler,
condition,
continueAsNew,
proxyActivities,
ApplicationFailure,
} from '@temporalio/workflow';
import type * as activities from './activities';
const { applyChange, notifyOwner } = proxyActivities<typeof activities>({
startToCloseTimeout: '5 minutes',
retry: { maximumAttempts: 3 },
});
// --- Definitions (export for client use) ---
export const addItemSignal = defineSignal<[string]>('addItem');
export const getStateQuery = defineQuery<EntityState>('getState');
export const setPriorityUpdate = defineUpdate<string, [number]>('setPriority');
interface EntityState {
items: string[];
priority: number;
status: string;
}
const MAX_ITERATIONS = 500;
export async function managedEntityWorkflow(
entityId: string,
initialState?: Partial<EntityState>,
): Promise<void> {
// --- Mutable state ---
const state: EntityState = {
items: initialState?.items ?? [],
priority: initialState?.priority ?? 1,
status: initialState?.status ?? 'active',
};
// --- Signal handler (fire-and-forget, sync only) ---
setHandler(addItemSignal, (item: string) => {
state.items.push(item);
});
// --- Query handler (read-only, must be sync) ---
setHandler(getStateQuery, () => ({ ...state }));
// --- Update handler (can be async, can return values) ---
setHandler(
setPriorityUpdate,
async (newPriority: number) => {
const old = state.priority;
state.priority = newPriority;
await notifyOwner(`Priority changed from ${old} to ${newPriority}`);
return `Priority set to ${newPriority}`;
},
{
validator: (newPriority: number) => {
if (newPriority < 1 || newPriority > 10) {
throw ApplicationFailure.nonRetryable(
'Priority must be between 1 and 10'
);
}
},
},
);
// --- Main loop ---
for (let i = 0; i < MAX_ITERATIONS; i++) {
const hasNewItems = await condition(
() => state.items.length > 0,
'1 hour',
);
if (hasNewItems) {
const batch = state.items.splice(0, state.items.length);
for (const item of batch) {
await applyChange({ entityId, item });
}
}
}
// Pass current state forward across continueAsNew
await continueAsNew<typeof managedEntityWorkflow>(entityId, state);
}
import { Client } from '@temporalio/client';
import {
managedEntityWorkflow,
addItemSignal,
getStateQuery,
setPriorityUpdate,
} from './workflows';
const client = new Client(/* connection config */);
// --- Start the entity workflow ---
const handle = await client.workflow.start(managedEntityWorkflow, {
workflowId: `entity-${entityId}`, // deterministic ID = entity ID
taskQueue: 'my-task-queue',
args: [entityId],
});
// --- Signal (fire-and-forget) ---
await handle.signal(addItemSignal, 'new-item');
// --- Query (read-only, immediate response) ---
const state = await handle.query(getStateQuery);
// --- Update (request-response, can await result) ---
const result = await handle.executeUpdate(setPriorityUpdate, {
args: [5],
waitForStage: 'COMPLETED',
});
// --- Or start update and poll later ---
const updateHandle = await handle.startUpdate(setPriorityUpdate, {
args: [7],
updateId: 'priority-update-001',
waitForStage: 'ACCEPTED',
});
const updateResult = await updateHandle.result();
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'my-task-queue',
// For Temporal Cloud:
// connection: { address: 'my-ns.abc123.tmprl.cloud:7233', tls: { ... } }
});
await worker.run();
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
Wrong: Calling continueAsNew immediately without processing buffered signals. Those signals are lost.
Right: Use a while (pendingUpdates.length) loop to process all buffered signals before continueAsNew.
Wrong: await condition(() => pendingUpdates.length > 0) with no timeout. The workflow never calls continueAsNew if no signals arrive, and history grows unbounded.
Right: Always pass a timeout: await condition(() => pendingUpdates.length > 0, '1 day').
Wrong: Calling activities or doing I/O inside a signal handler. Signal handlers must be synchronous.
Right: Buffer the signal data, then process it in the main workflow loop where you can await activities.
Wrong: await continueAsNew<typeof myWorkflow>(entityId) -- loses all accumulated state.
Right: await continueAsNew<typeof myWorkflow>(entityId, currentState) and accept that state as an argument.
Wrong: Random workflow IDs for entities. You cannot find or signal the workflow later.
Right: Use a deterministic ID: workflowId: 'entity-${entityId}'. Temporal deduplicates by workflow ID.
Wrong: setHandler(myQuery, async () => { state.x = 1; return state; }). Queries must be synchronous and read-only.
Right: setHandler(myQuery, () => ({ ...state })). Return a copy, no mutations, no await.
Wrong: No try/catch around the main loop. Cancellation throws CancelledFailure and skips cleanup.
Right: Wrap in try/catch, check isCancellation(err), run cleanup in CancellationScope.nonCancellable.
View event history in the Temporal Web UI or via tctl workflow show -w <workflowId>. Look for WorkflowExecutionContinuedAsNew events to confirm the pattern is cycling.
Check history length with handle.describe() -- if historyLength is growing past a few thousand, your MAX_ITERATIONS may be too high or continueAsNew is not firing.
Signal delivery issues? Signals are buffered. If the workflow is stuck in an activity, signals queue up and are delivered when the workflow yields. Check that your condition() or main loop actually processes them.
Update validation failures show up as ApplicationFailure on the client side. Check the validator function logic.
Worker not picking up tasks? Verify the taskQueue matches between client start options and worker config. Check the worker logs for registration messages.
Workflow replaying unexpectedly? This is normal after worker restarts. Ensure your workflow code is deterministic (no Date.now(), no Math.random(), no direct I/O).
Use temporal workflow list (Temporal CLI) to see running entity workflows and their status.
If this skill does not answer your question, use Context7 to search /temporalio/sdk-typescript or /temporalio/samples-typescript for more details.