Distributed systems are hard. Processes crash, networks fail, and services go down at the worst possible moments. Traditional job queues handle simple tasks, but what happens when you need a workflow that spans hours, involves multiple services, and must survive server restarts?
Temporal.io solves this problem by providing a durable execution platform where your code runs as if failures simply don't happen. Your workflows persist through crashes, retries happen automatically, and you get full visibility into every step of execution.
In this tutorial, you'll build a production-ready order processing workflow using Temporal.io and TypeScript. By the end, you'll understand how to model complex business processes as reliable, observable workflows that never lose state.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- TypeScript experience at an intermediate level
- Basic understanding of async/await and Promises
- Docker installed (optional — Temporal CLI handles local dev)
- Familiarity with REST APIs
What You'll Build
You'll create an order processing system with these steps:
- Validate the order and check inventory
- Charge the customer's payment method
- Send a confirmation email
- Schedule fulfillment and shipping
- Handle failures at any step with automatic retries
This workflow will be fully fault-tolerant — if your server crashes mid-process, Temporal resumes exactly where it left off when the server restarts.
Step 1: Set Up the Temporal Development Server
The easiest way to run Temporal locally is using the Temporal CLI. Install it via Homebrew on macOS:
brew install temporalOr download it from the official Temporal CLI releases page for Linux and Windows.
Start the Temporal development server:
temporal server start-devThis starts:
- Temporal Server on port 7233
- Web UI at
http://localhost:8233
Open the Web UI in your browser. You'll see the Temporal dashboard where you can monitor all workflow executions in real time. Keep this terminal running throughout the tutorial.
Step 2: Initialize the TypeScript Project
Create a new Node.js project with TypeScript:
mkdir temporal-order-processing
cd temporal-order-processing
npm init -yInstall the Temporal SDK and TypeScript dependencies:
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
npm install -D typescript ts-node @types/nodeInitialize TypeScript configuration:
npx tsc --initUpdate tsconfig.json with these settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Create the project structure:
mkdir -p src/{workflows,activities,workers,client,shared}Your final directory layout will look like this:
temporal-order-processing/
├── src/
│ ├── shared/
│ │ └── types.ts
│ ├── workflows/
│ │ └── order-workflow.ts
│ ├── activities/
│ │ └── order-activities.ts
│ ├── workers/
│ │ └── worker.ts
│ └── client/
│ └── start-workflow.ts
├── tsconfig.json
└── package.json
Step 3: Understand Core Temporal Concepts
Before writing code, let's understand the four key building blocks:
Workflows
A Workflow is a durable function that orchestrates your business logic. Workflows are deterministic — given the same inputs, they always produce the same sequence of operations. This determinism is what allows Temporal to replay and recover workflows after failures without data loss or duplicate side effects.
Key constraints for workflows:
- No direct I/O (no database calls, HTTP requests, file system access)
- No use of
Date.now()orMath.random()directly - No non-deterministic operations
- All side effects must go through Activities
Activities
An Activity is where your actual business logic lives — database calls, API requests, sending emails. Activities are allowed to fail and will be retried according to your configured retry policy.
Workers
A Worker is a process that hosts your workflows and activities. It polls the Temporal server for tasks and executes them. You can run multiple workers for horizontal scaling with zero configuration changes.
The Temporal Client
The Client is used to start workflows and send signals to running workflows. It communicates with the Temporal server to schedule workflow executions from your API or application layer.
Step 4: Define Shared Types
Create a shared types file for your workflow inputs and outputs:
// src/shared/types.ts
export interface OrderItem {
productId: string;
quantity: number;
price: number;
}
export interface OrderInput {
orderId: string;
customerId: string;
items: OrderItem[];
totalAmount: number;
paymentMethodId: string;
}
export interface OrderResult {
orderId: string;
status: "completed" | "failed" | "cancelled";
chargeId?: string;
trackingNumber?: string;
message: string;
}Step 5: Write the Activities
Activities contain the real business logic. Each activity runs in the worker process and can make any I/O call:
// src/activities/order-activities.ts
import { OrderInput } from "../shared/types";
export async function validateOrder(input: OrderInput): Promise<boolean> {
console.log(`Validating order ${input.orderId}`);
// Simulate inventory check (replace with real DB query)
await new Promise((resolve) => setTimeout(resolve, 100));
if (input.items.length === 0) {
throw new Error("Order must contain at least one item");
}
if (input.totalAmount <= 0) {
throw new Error("Order total must be greater than zero");
}
console.log(`Order ${input.orderId} validated successfully`);
return true;
}
export async function chargePayment(
orderId: string,
amount: number,
paymentMethodId: string
): Promise<string> {
console.log(`Charging ${amount} for order ${orderId}`);
// Simulate payment provider call (Stripe, etc.)
await new Promise((resolve) => setTimeout(resolve, 500));
const chargeId = `ch_${Date.now()}_${orderId}`;
console.log(`Payment successful: ${chargeId}`);
return chargeId;
}
export async function sendConfirmationEmail(
orderId: string,
customerId: string,
chargeId: string
): Promise<void> {
console.log(`Sending confirmation email for order ${orderId}`);
// Use Resend or SendGrid here in production
await new Promise((resolve) => setTimeout(resolve, 200));
console.log(`Confirmation email sent to customer ${customerId}`);
}
export async function scheduleShipping(
orderId: string,
itemCount: number
): Promise<string> {
console.log(`Scheduling shipping for order ${orderId} (${itemCount} items)`);
// Call your fulfillment provider API
await new Promise((resolve) => setTimeout(resolve, 300));
const trackingNumber = `TRK${Date.now()}`;
console.log(`Shipping scheduled: ${trackingNumber}`);
return trackingNumber;
}Activities can make any I/O calls — database queries, HTTP requests, file system access. They're the only place where side effects should occur in a Temporal workflow. Keep each activity focused on a single responsibility.
Step 6: Write the Order Workflow
Now create the workflow that orchestrates these activities in sequence:
// src/workflows/order-workflow.ts
import {
proxyActivities,
defineSignal,
defineQuery,
setHandler,
} from "@temporalio/workflow";
import type * as activities from "../activities/order-activities";
import type { OrderInput, OrderResult } from "../shared/types";
// Configure activity proxies with retry policy
const {
validateOrder,
chargePayment,
sendConfirmationEmail,
scheduleShipping,
} = proxyActivities<typeof activities>({
startToCloseTimeout: "30 seconds",
retry: {
maximumAttempts: 3,
initialInterval: "1 second",
maximumInterval: "10 seconds",
backoffCoefficient: 2,
},
});
// Signal for order cancellation
export const cancelOrderSignal = defineSignal<[string]>("cancelOrder");
// Query to inspect current status
export const getOrderStatusQuery = defineQuery<string>("getOrderStatus");
export async function orderWorkflow(input: OrderInput): Promise<OrderResult> {
let cancelled = false;
let cancellationReason = "";
let currentStatus = "pending";
// Wire up signal and query handlers
setHandler(cancelOrderSignal, (reason: string) => {
cancelled = true;
cancellationReason = reason;
});
setHandler(getOrderStatusQuery, () => currentStatus);
try {
// Step 1: Validate
currentStatus = "validating";
await validateOrder(input);
if (cancelled) {
return {
orderId: input.orderId,
status: "cancelled",
message: `Order cancelled before payment: ${cancellationReason}`,
};
}
// Step 2: Payment — use maximumAttempts: 1 for idempotency
currentStatus = "charging";
const chargeId = await chargePayment(
input.orderId,
input.totalAmount,
input.paymentMethodId
);
if (cancelled) {
// Payment already charged — trigger refund logic here
return {
orderId: input.orderId,
status: "cancelled",
chargeId,
message: `Order cancelled after payment. Refund initiated.`,
};
}
// Step 3: Confirmation email (non-critical)
currentStatus = "confirming";
await sendConfirmationEmail(input.orderId, input.customerId, chargeId);
// Step 4: Fulfillment
currentStatus = "shipping";
const trackingNumber = await scheduleShipping(
input.orderId,
input.items.length
);
currentStatus = "completed";
return {
orderId: input.orderId,
status: "completed",
chargeId,
trackingNumber,
message: "Order processed successfully",
};
} catch (error) {
currentStatus = "failed";
return {
orderId: input.orderId,
status: "failed",
message: error instanceof Error ? error.message : "Unknown error",
};
}
}Never import Node.js built-ins like fs, http, or crypto directly in workflow files. The Temporal workflow sandbox blocks these. Use type-only imports from your activity files and only import runtime helpers from @temporalio/workflow.
Step 7: Set Up the Worker
The worker process bridges the Temporal server and your application code:
// src/workers/worker.ts
import { Worker, NativeConnection } from "@temporalio/worker";
import * as activities from "../activities/order-activities";
import path from "path";
async function run() {
const connection = await NativeConnection.connect({
address: "localhost:7233",
});
const worker = await Worker.create({
connection,
namespace: "default",
taskQueue: "order-processing",
workflowsPath: path.join(__dirname, "../workflows"),
activities,
});
console.log("Worker started — polling for tasks on queue: order-processing");
await worker.run();
}
run().catch((err) => {
console.error("Worker failed to start:", err);
process.exit(1);
});Step 8: Create the Workflow Client
The client triggers workflow executions and can query or signal running ones:
// src/client/start-workflow.ts
import { Client, Connection } from "@temporalio/client";
import {
orderWorkflow,
cancelOrderSignal,
getOrderStatusQuery,
} from "../workflows/order-workflow";
import { OrderInput } from "../shared/types";
async function main() {
const connection = await Connection.connect({ address: "localhost:7233" });
const client = new Client({ connection, namespace: "default" });
const orderInput: OrderInput = {
orderId: "ORDER-001",
customerId: "CUST-123",
items: [
{ productId: "PROD-A", quantity: 2, price: 29.99 },
{ productId: "PROD-B", quantity: 1, price: 49.99 },
],
totalAmount: 109.97,
paymentMethodId: "pm_card_visa",
};
// Start the workflow
const handle = await client.workflow.start(orderWorkflow, {
taskQueue: "order-processing",
workflowId: `order-${orderInput.orderId}`,
args: [orderInput],
});
console.log(`Workflow started: ${handle.workflowId}`);
// Poll status every second until complete
let done = false;
while (!done) {
const status = await handle.query(getOrderStatusQuery);
console.log(`Current status: ${status}`);
if (status === "completed" || status === "failed") done = true;
await new Promise((r) => setTimeout(r, 1000));
}
// Await final result
const result = await handle.result();
console.log("Final result:", result);
await connection.close();
}
main().catch(console.error);Step 9: Add npm Scripts and Run
Update package.json:
{
"scripts": {
"worker": "ts-node src/workers/worker.ts",
"start": "ts-node src/client/start-workflow.ts"
}
}Open three terminal windows:
Terminal 1 — Temporal Server:
temporal server start-devTerminal 2 — Worker Process:
npm run workerTerminal 3 — Trigger a Workflow:
npm run startYou'll see each activity log in the worker terminal and real-time status updates in the client terminal. Open http://localhost:8233 to watch the full event history in the Web UI — including each activity's input, output, retry count, and duration.
Step 10: Retry Policies Explained
Understanding retry behavior is critical for production systems. Here's what each setting controls:
proxyActivities({
startToCloseTimeout: "30 seconds", // Max wall-clock time per attempt
scheduleToCloseTimeout: "5 minutes", // Max total time including all retries
retry: {
maximumAttempts: 3,
initialInterval: "1 second", // First retry after 1s
maximumInterval: "30 seconds", // Never wait more than 30s
backoffCoefficient: 2, // Exponential: 1s, 2s, 4s...
nonRetryableErrorTypes: ["PaymentDeclinedError"],
},
})For payment activities, set maximumAttempts: 1 to prevent double-charging. For email delivery, allow up to 5 retries — email services have transient failures that usually self-resolve.
Step 11: Long-Running Workflows with sleep
Temporal's sleep function pauses a workflow for any duration — seconds to months — without holding a thread or consuming resources:
import { sleep } from "@temporalio/workflow";
export async function subscriptionRenewalWorkflow(userId: string) {
// Wait 30 days before renewal
await sleep("30 days");
await chargeRenewalFee(userId);
await sendRenewalConfirmation(userId);
// Recurse to schedule the next renewal cycle
await sleep("30 days");
}This is one of Temporal's most powerful features — replace your cron jobs with readable workflow code that handles retries and state automatically.
Step 12: Testing with TestWorkflowEnvironment
Temporal provides a test runtime so you can test workflows without a live server:
// src/__tests__/order-workflow.test.ts
import { TestWorkflowEnvironment } from "@temporalio/testing";
import { Worker } from "@temporalio/worker";
import { orderWorkflow } from "../workflows/order-workflow";
import * as activities from "../activities/order-activities";
describe("Order Workflow", () => {
let testEnv: TestWorkflowEnvironment;
beforeAll(async () => {
testEnv = await TestWorkflowEnvironment.createLocal();
});
afterAll(async () => {
await testEnv.teardown();
});
it("completes successfully for valid order", async () => {
const { client, nativeConnection } = testEnv;
const worker = await Worker.create({
connection: nativeConnection,
namespace: "default",
taskQueue: "test-queue",
workflowsPath: require.resolve("../workflows/order-workflow"),
activities,
});
const result = await worker.runUntil(
client.workflow.execute(orderWorkflow, {
taskQueue: "test-queue",
workflowId: "test-001",
args: [{
orderId: "TEST-001",
customerId: "CUST-001",
items: [{ productId: "P1", quantity: 1, price: 10 }],
totalAmount: 10,
paymentMethodId: "pm_test",
}],
})
);
expect(result.status).toBe("completed");
expect(result.chargeId).toBeDefined();
expect(result.trackingNumber).toBeDefined();
});
});Install the testing package:
npm install -D @temporalio/testingStep 13: Integrate with an Express API
Connect Temporal to your HTTP layer so your frontend can trigger and monitor workflows:
// src/api/server.ts
import express from "express";
import { Client, Connection } from "@temporalio/client";
import { orderWorkflow, getOrderStatusQuery } from "../workflows/order-workflow";
import type { OrderInput } from "../shared/types";
const app = express();
app.use(express.json());
let temporalClient: Client;
async function initClient() {
const connection = await Connection.connect({ address: "localhost:7233" });
temporalClient = new Client({ connection, namespace: "default" });
console.log("Temporal client connected");
}
// POST /orders — starts the workflow
app.post("/orders", async (req, res) => {
try {
const input: OrderInput = req.body;
const handle = await temporalClient.workflow.start(orderWorkflow, {
taskQueue: "order-processing",
workflowId: `order-${input.orderId}`,
args: [input],
});
res.json({ workflowId: handle.workflowId });
} catch {
res.status(500).json({ error: "Failed to start workflow" });
}
});
// GET /orders/:id/status — queries the running workflow
app.get("/orders/:id/status", async (req, res) => {
try {
const handle = temporalClient.workflow.getHandle(`order-${req.params.id}`);
const status = await handle.query(getOrderStatusQuery);
res.json({ orderId: req.params.id, status });
} catch {
res.status(404).json({ error: "Order workflow not found" });
}
});
initClient().then(() => {
app.listen(3001, () => console.log("API running on port 3001"));
});Troubleshooting
"Connection refused" when starting the worker:
Ensure temporal server start-dev is running in a separate terminal and port 7233 is available.
Workflow stuck in "Running" state indefinitely: Check that your worker process is running and connected to the same task queue name used in the client. Inspect the workflow event history in the Web UI for detailed error messages.
Activities not retrying after failure:
Verify your retry policy is configured on proxyActivities, not inside the workflow function body. Also confirm the error is not wrapped as a non-retryable ApplicationFailure.
TypeScript errors in workflow file:
Remember workflows run in a sandboxed environment. Avoid Node.js built-ins and use import type for activity file imports to prevent bundling activity code into the workflow bundle.
Next Steps
With a working Temporal setup, explore these advanced patterns:
- Child Workflows: Decompose complex workflows into reusable sub-workflows
- Schedules: Replace cron jobs with Temporal's built-in schedule API
- Saga Pattern: Implement distributed transactions with compensating activities
- Versioning API: Update running workflows safely using
patched() - Temporal Cloud: Fully managed Temporal for production — no infrastructure to maintain
- Nexus: Cross-namespace workflow calls for large microservice architectures
Conclusion
You've built a production-ready order processing system with Temporal.io and TypeScript. Your workflow survives server crashes, retries failed activities automatically, and exposes real-time status through queries — all with straightforward, readable code.
The real power of Temporal is that it eliminates an entire class of distributed systems problems: you no longer need to hand-roll retry logic, manage job queue state, or build compensating transaction handlers from scratch. Whether you're building payment flows, user onboarding pipelines, data ETL jobs, or multi-step approval processes, Temporal gives your business logic the durability that production demands.