writing/tutorial/2026/05
TutorialMay 12, 2026·30 min read

Building Durable Workflows with Temporal.io and TypeScript

Master Temporal.io for building fault-tolerant, durable workflows in TypeScript. Learn workers, activities, signals, queries, and production deployment patterns.

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:

  1. Validate the order and check inventory
  2. Charge the customer's payment method
  3. Send a confirmation email
  4. Schedule fulfillment and shipping
  5. 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 temporal

Or download it from the official Temporal CLI releases page for Linux and Windows.

Start the Temporal development server:

temporal server start-dev

This 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 -y

Install the Temporal SDK and TypeScript dependencies:

npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
npm install -D typescript ts-node @types/node

Initialize TypeScript configuration:

npx tsc --init

Update 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() or Math.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-dev

Terminal 2 — Worker Process:

npm run worker

Terminal 3 — Trigger a Workflow:

npm run start

You'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/testing

Step 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.