Building Long-Running Workflows with Temporal.io + TypeScript SDK — Declaring Sagas, Retries, and Signals in Code
Have you ever been asked, "The payment went through, but inventory reservation failed — how do we handle the refund?" in a microservices environment? I still vividly remember losing three days writing message broker-based compensation logic by hand the first time I ran into distributed transaction problems. State tracking tables, retry queues on failure, compensation event publishing — the code kept growing, yet actual business logic was nowhere to be found.
Temporal.io confines this problem inside the workflow code itself. Retry policies, Saga compensation flows, and long-running state management are declared inside TypeScript functions, not in separate infrastructure. Even if the server restarts or the network drops, the workflow resumes from exactly the point it stopped. No need to persist state yourself.
In this article, we explore how to structure Temporal workflows with the TypeScript SDK, how to declare distributed retries with proxyActivities and Retry Policy, and how to apply the Saga pattern to an e-commerce order processing scenario, all with code examples.
Core Concepts
The Problem Temporal Solves: Durable Execution
Traditional background job systems lose execution state when the process dies. Temporal persistently stores workflow execution history on the server using event sourcing. When a worker restarts, it replays the history to restore the interrupted point and continues execution from there.
This is called durable execution. From the developer's perspective, it feels like "just writing functions," while internally, checkpoints are managed automatically.
Workflow vs Activity: What Goes Where
Here lies Temporal's most important distinction.
| Category | Workflow | Activity |
|---|---|---|
| Role | Declare business flow, orchestration | External API calls, DB queries, file I/O |
| Determinism | Required — same result guaranteed on re-execution | Unconstrained |
| External I/O | Forbidden (fetch, DB, filesystem, etc.) | Free to use |
| Time & Random | Date.now(), Math.random(), setTimeout are auto-replaced by the sandbox with replay-safe versions |
Use as-is |
| Failure Handling | Orchestrate compensation logic | Auto-retry according to Retry Policy |
The TypeScript SDK's characteristics are apparent in this table. Unlike other language SDKs, the TS SDK's workflow sandbox automatically replaces Date.now(), new Date(), Math.random(), and setTimeout with deterministic versions. This ensures the same values are returned during replay as during the initial execution.
In other words, what must not go into a workflow is "code that communicates with the outside world." fetch, DB clients, file systems, gRPC calls — anything whose result may differ each time must be moved into an Activity.
Core API: proxyActivities and Retry Policy
In the TypeScript SDK, a Workflow does not import Activity functions directly but uses them in proxy form. The retry policy is declared here.
Note: The
proxyActivitiescall below must be executed at the top-level scope of the workflow module. Calling it inside a workflow function creates a new proxy on every replay, breaking determinism. The standard practice is to place it at the top of the workflow file, above anyexport async function.
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const {
reserveInventory,
processPayment,
arrangeShipping,
sendConfirmationEmail,
updateOrderStatus,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
initialInterval: '1s',
backoffCoefficient: 2,
maximumAttempts: 5,
nonRetryableErrorTypes: ['CreditCardExpiredException', 'InvalidOrderException'],
},
});
// Separate proxy for compensation Activities — more aggressive retry
const {
releaseInventory,
refundPayment,
cancelShipping,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '1m',
retry: {
initialInterval: '2s',
backoffCoefficient: 2,
maximumAttempts: 20,
},
});nonRetryableErrorTypes is especially important. Registering failures that are pointless to retry — such as an expired card error — here causes them to fail immediately without unnecessary retries.
There is a reason for separating compensation Activities into a separate proxy — it is a point often missed in practice, and I will revisit it in the Saga section shortly.
Saga Pattern: Declaring Compensation Flow with try/catch
The Saga pattern splits a distributed transaction into a series of local transactions, and if one fails midway, it reverses the previously successful steps in reverse order. In Temporal, this is expressed naturally as ordinary TypeScript try/catch blocks.
import { log } from '@temporalio/workflow';
export async function orderWorkflow(orderId: string): Promise<void> {
const compensations: Array<() => Promise<void>> = [];
try {
await reserveInventory(orderId);
compensations.push(() => releaseInventory(orderId));
await processPayment(orderId);
compensations.push(() => refundPayment(orderId));
await arrangeShipping(orderId);
compensations.push(() => cancelShipping(orderId));
await sendConfirmationEmail(orderId);
} catch (err) {
for (const compensate of [...compensations].reverse()) {
try {
await compensate();
} catch (compensationErr) {
// Log compensation failures but continue with remaining compensations
log.error('Compensation failed', { orderId, compensationErr });
}
}
throw err;
}
}There are three points to note here.
- Push the compensation function onto the stack immediately after the corresponding step succeeds. When
reserveInventorysucceeds, register its counterpartreleaseInventory. Since they execute in reverse order on failure, the most recently successful step is undone first. - Copy the array before reversing with
[...compensations].reverse(). Sincereverse()mutates the original, this pattern avoids unexpected side effects when the pattern is reused elsewhere. - Continue with the remaining compensations even if one compensation fails. Skipping the payment refund just because inventory cancellation failed is problematic. This is why compensation Activities were set up with a separate proxy with more aggressive retries (e.g., 20 attempts). If a compensation ultimately fails, log it and escalate to a dead-letter process if needed.
Practical Application
Complete Structure for E-Commerce Order Processing Scenario
Based on a real project structure, the file layout looks like this.
src/
workflows/
orderWorkflow.ts ← Declare business flow
activities/
inventoryActivities.ts
paymentActivities.ts
shippingActivities.ts
notificationActivities.ts
orderActivities.ts
worker.ts ← Worker process entry point
client.ts ← Start workflowActivity Implementation — External I/O is consolidated here.
// activities/paymentActivities.ts
import { ApplicationFailure } from '@temporalio/activity';
export async function processPayment(orderId: string): Promise<void> {
const result = await paymentGateway.charge(orderId);
if (result.errorCode === 'CARD_EXPIRED') {
throw ApplicationFailure.nonRetryable(
'Card has expired',
'CreditCardExpiredException',
);
}
if (!result.success) {
throw new Error(`Payment failed: ${result.message}`);
}
}
export async function refundPayment(orderId: string): Promise<void> {
await paymentGateway.refund(orderId);
}Worker Registration — Register Workflow and Activity on the same Task Queue.
// worker.ts
import { Worker } from '@temporalio/worker';
import * as inventoryActivities from './activities/inventoryActivities';
import * as paymentActivities from './activities/paymentActivities';
import * as shippingActivities from './activities/shippingActivities';
import * as notificationActivities from './activities/notificationActivities';
import * as orderActivities from './activities/orderActivities';
async function run() {
// Note: When spreading multiple modules, if exports share the same name,
// later modules silently overwrite earlier ones. Keep export names unique
// across files, or explicitly build a prefixed object.
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows/orderWorkflow'),
activities: {
...inventoryActivities,
...paymentActivities,
...shippingActivities,
...notificationActivities,
...orderActivities,
},
taskQueue: 'order-processing',
});
await worker.run();
}
run().catch(console.error);Starting the Workflow from a Client
// client.ts
import { Client, Connection } from '@temporalio/client';
async function startOrder(orderId: string) {
const connection = await Connection.connect({ address: 'localhost:7233' });
const client = new Client({ connection });
const handle = await client.workflow.start('orderWorkflow', {
taskQueue: 'order-processing',
workflowId: `order-${orderId}`,
args: [orderId],
});
console.log(`Workflow started: ${handle.workflowId}`);
const result = await handle.result();
return result;
}Order Processing Flow and Compensation Paths on Failure
The compensation nodes are shown as single steps for simplicity, but the actual execution runs in reverse order of registration — cancel shipping, refund payment, cancel inventory reservation.
Injecting External Events with Signals: Delivery Status Update and Timeout
One of the strengths of long-running workflows is the ability to receive external events via Signals and continue the flow. For example, a pattern that waits for a delivery completion notification can be written like this. You must handle the case where no Signal arrives even after 7 days.
import { defineSignal, setHandler, condition } from '@temporalio/workflow';
export const deliveryConfirmedSignal = defineSignal<[{ trackingCode: string }]>(
'deliveryConfirmed',
);
export async function orderWorkflow(orderId: string): Promise<void> {
// ... Saga logic ...
let delivered = false;
let trackingCode = '';
setHandler(deliveryConfirmedSignal, ({ trackingCode: code }) => {
delivered = true;
trackingCode = code;
});
// condition returns true when the condition becomes true, or false on timeout
const arrived = await condition(() => delivered, '7d');
if (arrived) {
await updateOrderStatus(orderId, 'DELIVERED', trackingCode);
} else {
// Carrier webhook not received — forward to an investigation queue or start a retry workflow
await updateOrderStatus(orderId, 'DELIVERY_TIMEOUT', '');
}
}Sending a Signal from an external system (such as a carrier webhook) is just one line from the client.
await client.workflow
.getHandle(`order-${orderId}`)
.signal(deliveryConfirmedSignal, { trackingCode: 'KR1234567890' });Local Testing: TestWorkflowEnvironment
Workflows can be tested without a Temporal server. Using TestWorkflowEnvironment from @temporalio/testing, you can replace Activities with mocks and skip time-based waits like sleep.
import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
let testEnv: TestWorkflowEnvironment;
beforeAll(async () => {
testEnv = await TestWorkflowEnvironment.createLocal();
});
afterAll(async () => {
await testEnv.teardown();
});
test('inventory reservation is cancelled when payment fails', async () => {
const { client, nativeConnection } = testEnv;
const mockActivities = {
reserveInventory: jest.fn().mockResolvedValue(undefined),
processPayment: jest.fn().mockRejectedValue(new Error('Payment failed')),
releaseInventory: jest.fn().mockResolvedValue(undefined),
refundPayment: jest.fn().mockResolvedValue(undefined),
arrangeShipping: jest.fn().mockResolvedValue(undefined),
cancelShipping: jest.fn().mockResolvedValue(undefined),
sendConfirmationEmail: jest.fn().mockResolvedValue(undefined),
updateOrderStatus: jest.fn().mockResolvedValue(undefined),
};
const worker = await Worker.create({
connection: nativeConnection,
taskQueue: 'test-queue',
workflowsPath: require.resolve('./workflows/orderWorkflow'),
activities: mockActivities,
});
await worker.runUntil(async () => {
// The expected behavior for this test is that the workflow ultimately fails.
// Therefore, verify the failure itself with rejects.toThrow, then confirm compensation was called.
await expect(
client.workflow.execute('orderWorkflow', {
taskQueue: 'test-queue',
workflowId: 'test-order-1',
args: ['order-1'],
}),
).rejects.toThrow(/Payment failed/);
});
expect(mockActivities.reserveInventory).toHaveBeenCalledWith('order-1');
expect(mockActivities.releaseInventory).toHaveBeenCalledWith('order-1');
expect(mockActivities.refundPayment).not.toHaveBeenCalled();
});Pros and Cons
Pros
| Item | Description |
|---|---|
| Code-First Approach | Declare workflows in TypeScript code without BPMN diagrams; full IDE autocomplete and type checking support |
| Durable Execution | Resume from exact point after process restart or network disconnection — no need to implement checkpoint logic yourself |
| Declarative Retries | Specify exponential backoff, max attempts, and non-retryable error types with just the retry option |
| Natural Saga Implementation | Implement distributed transaction compensation with try/catch + compensation function pattern |
| Visibility | View history, status, and errors of all workflow instances in real time via Temporal Web UI |
| Testing Support | Mock-based unit testing locally with TestWorkflowEnvironment |
| Multi-Language SDKs | Supports TypeScript, Go, Java, Python, and .NET for polyglot environments |
Cons and Considerations
| Item | Description |
|---|---|
| Learning Curve | Understanding the determinism concept and event-sourcing-based execution model requires significant time |
| Workflow Code Constraints | Direct external I/O calls (fetch, DB, filesystem) are forbidden — all must be delegated to Activities |
| Infrastructure Complexity | Self-hosting requires operating Temporal server, DB (PostgreSQL or Cassandra), and Elasticsearch clusters |
| History Size Limit | Default upper limit on single workflow event count (approx. 50K events / 50MiB) — long-running workflows need continueAsNew |
| Potential Overkill | For simple queue-based tasks or short background jobs, the adoption cost may exceed the ROI |
Three Common Mistakes in Practice
1. Making Real External I/O Calls Inside a Workflow
As mentioned earlier, the TS SDK sandbox automatically replaces standard APIs like Date.now(), Math.random(), and setTimeout with deterministic versions. So these functions themselves can be used inside a workflow without breaking replay.
However, real external I/O such as fetch, DB clients, and filesystem access is still forbidden. The most common mistake developers new to Temporal make is carelessly initializing external service clients at the top of a workflow file, or calling fetch directly thinking "it's just a simple call." Such calls must be moved to Activities.
// Wrong: direct HTTP call inside a workflow
export async function orderWorkflow(orderId: string) {
const res = await fetch(`https://api.example.com/orders/${orderId}`); // Forbidden
}
// Correct: wrap in an Activity and call via proxy
export async function orderWorkflow(orderId: string) {
const order = await fetchOrderDetails(orderId); // function exposed via proxyActivities
}In the special case where you need the current wall-clock time rather than the workflow start time, it is safe to retrieve it from an Activity and return it to the workflow.
2. Omitting continueAsNew in Long-Running Workflows
Workflows running for months or more (subscription renewals, user onboarding sequences, etc.) can reach the event history limit (default approx. 50K events or 50MiB). Exceeding this limit prevents the workflow from progressing. In practice, continueAsNew should be triggered based on actual history length, not iteration count, for reliable behavior.
import { continueAsNew, workflowInfo } from '@temporalio/workflow';
export async function longRunningWorkflow(iteration: number): Promise<void> {
// ... perform work ...
// Safely hand off to a new instance when roughly halfway to the limit
if (workflowInfo().historyLength > 10_000) {
await continueAsNew<typeof longRunningWorkflow>(iteration + 1);
}
}3. Adopting Temporal for Simple Tasks
For something as simple as "an API call that needs 3 retries," Temporal is overkill. Temporal shows its true value in scenarios like compensation transactions spanning multiple services, long-running executions lasting hours to days, and waiting for external events. Layering a Temporal server, DB, and Worker process on top of something solvable with a single line of Sidekiq or BullMQ is overkill.
Closing
Temporal.io brings three problems — distributed transaction compensation, automatic retries, and long-running state management — inside the workflow code. Instead of designing a separate state tracking DB, retry queue, and compensation event topic, you declare the business flow with TypeScript functions and try/catch blocks.
The determinism rule — not making direct external I/O calls inside a workflow — is the most unfamiliar constraint at first, but it is also the core contract that makes durable execution possible. Since the TS SDK handles making Date.now(), Math.random(), and setTimeout deterministic automatically, the only remaining rule is "all communication with the outside world goes through Activities."
If you want to get started in a concrete, actionable order, here's how to approach it.
- Run a local Temporal server — Running the official docker-compose file with
docker compose upstarts the server atlocalhost:7233and the Web UI atlocalhost:8080. - Write a Hello World Workflow — Install the
@temporalio/worker,@temporalio/workflow, and@temporalio/clientpackages and follow the first example of the TypeScript course on the officiallearn.temporal.ioto get a grasp of the overall structure. - Migrate one existing background job to a Workflow — Rather than creating a new project, pick a "multi-step job with unclear failure handling" from your running service, split it into Activities, and wrap it in a Workflow. This will let you experience Temporal's value firsthand.
Being able to handle complex compensation logic and long-running state as "just code" — once you get used to it, it's hard to go back to the old way.
References
- Temporal Official Documentation — TypeScript SDK Developer Guide
- Activity Execution — TypeScript SDK Official Documentation
- Retry Policy Encyclopedia — Temporal Official Documentation
- Temporal Use Cases and Design Patterns
- Saga Compensating Transactions — Temporal Official Blog
- Mastering Saga Patterns for Distributed Transactions — Temporal Blog
- Replay 2025 New Feature Announcements — Temporal Blog
- Temporal TypeScript SDK Demo — Official Blog
- Node.js Durable Execution with Temporal + TS: Saga Patterns — Medium
- Implementing the Saga Pattern with Temporal (July 2026) — Medium
- Temporal Workflow Design Patterns — DZone
- Temporal Workflows and Activities Best Practices
- Learn Temporal — Official Learning Hub
- Temporal E-Commerce Order Fulfillment Demo