DEV Community

ThankGod Chibugwum Obobo
ThankGod Chibugwum Obobo

Posted on Originally published at actocodes.hashnode.dev

Durable Execution with Temporal.io: Building Workflows That Never Lose State

Distributed systems fail in partial and unpredictable ways. A payment workflow that processes a charge, updates inventory, sends a confirmation email, and notifies a fulfilment service has seven places to fail, and no built-in mechanism to pick up where it left off. The traditional response is a combination of message queues, idempotency keys, retry logic, state machines stored in a database, and compensating transactions, each adding complexity, each requiring its own failure handling, and each representing a subtle opportunity for state to be lost or corrupted.

Temporal.io is built on a different premise, what if your workflow code simply continued executing after a failure, a restart, or a crash, exactly where it left off, with all local variables intact, all function calls remembered, and no manual state management required?

This is durable execution, the guarantee that a workflow's progress is preserved indefinitely, regardless of what happens to the infrastructure running it. Temporal achieves this by recording every workflow event to a persistent log and replaying the workflow's history to reconstruct state after any failure. From the developer's perspective, you write ordinary sequential code. Temporal makes it fault-tolerant.

This guide covers Temporal's core concepts and shows how to implement durable workflows in TypeScript, including activities, retries, signals, timers, and child workflows, for the kinds of long-running business processes that break traditional queue-based approaches.

How Temporal Works: The Core Model

Understanding Temporal's execution model upfront prevents confusion later.

Workflows are deterministic functions that orchestrate work. They define what happens and in what order. Workflow code must be deterministic, the same inputs must always produce the same sequence of events, because Temporal replays workflow history to reconstruct state after failures.

Activities are non-deterministic units of work, the things that actually talk to the outside world, database writes, API calls, file operations, sending emails. Activities are executed by workers, can be retried independently, and their results are recorded in the workflow history.

Workers are long-running processes that poll Temporal's task queues and execute workflow and activity code. You run workers, Temporal runs the orchestration.

The Temporal Server persists workflow state, manages task queues, handles timers, and routes signals. It is the durable backbone, workers are stateless and replaceable.

The key insight: workflow code never talks to the outside world directly. It schedules activities and waits for results. Temporal records the activity result to the event history. If the worker dies before the workflow advances, Temporal replays the history, skipping already-completed activities using their recorded results, and the workflow continues from where it stopped. The activity does not re-execute. No duplicate charges. No double emails.

Step 1 - Installation and Project Setup

# Create a new TypeScript project
mkdir temporal-demo && cd temporal-demo
npm init -y
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
npm install -D typescript ts-node @types/node

# Start Temporal development server (requires Docker)
docker run --rm -p 7233:7233 -p 8080:8080 temporalio/auto-setup:latest
Enter fullscreen mode Exit fullscreen mode

Project structure:

/temporal-demo
  /src
    /workflows
      order-fulfillment.workflow.ts
      subscription-billing.workflow.ts
    /activities
      payment.activities.ts
      inventory.activities.ts
      notification.activities.ts
    worker.ts        ← registers and runs workflows + activities
    client.ts        ← starts workflows from your application
Enter fullscreen mode Exit fullscreen mode

Step 2 - Defining Activities

Activities are where side effects live. Each activity is a plain async function, no Temporal-specific decorators required:

// src/activities/payment.activities.ts
import { ApplicationFailure } from '@temporalio/activity';

export interface ChargeResult {
  transactionId: string;
  amount: number;
  status: 'succeeded' | 'failed';
}

export async function chargeCustomer(
  customerId: string,
  amount: number,
  idempotencyKey: string,
): Promise<ChargeResult> {
  try {
    // Call your payment provider (Stripe, etc.)
    const charge = await stripeClient.charges.create({
      customer: customerId,
      amount,
      currency: 'usd',
      idempotency_key: idempotencyKey,   // prevent duplicate charges on retry
    });

    return {
      transactionId: charge.id,
      amount: charge.amount,
      status: 'succeeded',
    };
  } catch (error) {
    if (error.code === 'card_declined') {
      // Non-retryable failure — throw ApplicationFailure with retryable: false
      throw new ApplicationFailure(
        'Card declined',
        'CARD_DECLINED',
        false,            // do not retry this activity
        [],
        error,
      );
    }
    // Retryable error — Temporal will retry according to retry policy
    throw error;
  }
}

export async function refundCustomer(
  transactionId: string,
  amount: number,
): Promise<void> {
  await stripeClient.refunds.create({
    charge: transactionId,
    amount,
  });
}
Enter fullscreen mode Exit fullscreen mode
// src/activities/inventory.activities.ts
export async function reserveInventory(
  items: OrderItem[],
  orderId: string,
): Promise<void> {
  await inventoryService.reserve(items, orderId);
}

export async function releaseInventory(
  orderId: string,
): Promise<void> {
  await inventoryService.release(orderId);
}
Enter fullscreen mode Exit fullscreen mode
// src/activities/notification.activities.ts
export async function sendOrderConfirmation(
  customerId: string,
  orderId: string,
  items: OrderItem[],
): Promise<void> {
  await emailService.send({
    to: customerId,
    template: 'order-confirmation',
    data: { orderId, items },
  });
}

export async function sendOrderFailureNotification(
  customerId: string,
  reason: string,
): Promise<void> {
  await emailService.send({
    to: customerId,
    template: 'order-failed',
    data: { reason },
  });
}
Enter fullscreen mode Exit fullscreen mode

Step 3 - Building the Order Fulfillment Workflow

This workflow orchestrates the full order lifecycle, charge, reserve inventory, confirm, with automatic compensation (refund + release inventory) if any step fails:

// src/workflows/order-fulfillment.workflow.ts
import { proxyActivities, sleep, defineSignal, setHandler, condition, ApplicationFailure } from '@temporalio/workflow';
import type * as PaymentActivities from '../activities/payment.activities';
import type * as InventoryActivities from '../activities/inventory.activities';
import type * as NotificationActivities from '../activities/notification.activities';

// Configure activity options - retry policy, timeouts
const { chargeCustomer, refundCustomer } = proxyActivities<typeof PaymentActivities>({
  startToCloseTimeout: '30 seconds',
  retry: {
    maximumAttempts: 3,
    initialInterval: '1 second',
    backoffCoefficient: 2,
    nonRetryableErrorTypes: ['CARD_DECLINED'],
  },
});

const { reserveInventory, releaseInventory } = proxyActivities<typeof InventoryActivities>({
  startToCloseTimeout: '10 seconds',
  retry: {
    maximumAttempts: 5,
    initialInterval: '500ms',
    backoffCoefficient: 1.5,
  },
});

const { sendOrderConfirmation, sendOrderFailureNotification } = proxyActivities<typeof NotificationActivities>({
  startToCloseTimeout: '15 seconds',
  retry: {
    maximumAttempts: 10,           // emails should retry aggressively
    initialInterval: '5 seconds',
  },
});

// Signals - external events that can influence workflow execution
export const cancelOrderSignal = defineSignal<[reason: string]>('cancelOrder');

export interface OrderInput {
  orderId: string;
  customerId: string;
  items: OrderItem[];
  totalAmount: number;
}

export async function orderFulfillmentWorkflow(input: OrderInput): Promise<string> {
  const { orderId, customerId, items, totalAmount } = input;

  let cancelRequested = false;
  let cancelReason = '';

  // Register signal handler - cancellation can arrive at any time
  setHandler(cancelOrderSignal, (reason: string) => {
    cancelRequested = true;
    cancelReason = reason;
  });

  // Step 1 - Check for cancellation before starting
  if (cancelRequested) {
    await sendOrderFailureNotification(customerId, cancelReason);
    return 'CANCELLED';
  }

  // Step 2 - Charge the customer
  let chargeResult;
  try {
    chargeResult = await chargeCustomer(
      customerId,
      totalAmount,
      orderId,     // use orderId as idempotency key — safe to retry
    );
  } catch (error) {
    await sendOrderFailureNotification(customerId, 'Payment could not be processed.');
    throw error;  // fail the workflow — no compensation needed, no charge was made
  }

  // Check for cancellation after payment
  if (cancelRequested) {
    await refundCustomer(chargeResult.transactionId, totalAmount);
    await sendOrderFailureNotification(customerId, cancelReason);
    return 'CANCELLED_AND_REFUNDED';
  }

  // Step 3 — Reserve inventory
  try {
    await reserveInventory(items, orderId);
  } catch (error) {
    // Inventory failed — refund the charge (compensating transaction)
    await refundCustomer(chargeResult.transactionId, totalAmount);
    await sendOrderFailureNotification(customerId, 'Items are out of stock.');
    throw error;
  }

  // Step 4 — Send confirmation
  await sendOrderConfirmation(customerId, orderId, items);

  return 'FULFILLED';
}
Enter fullscreen mode Exit fullscreen mode

The compensation pattern (refund on inventory failure) is expressed as ordinary sequential code, not a saga framework, not a choreography of events, not a state machine in a database. Temporal records every activity result. If the worker crashes during sendOrderConfirmation, Temporal replays the history, sees that chargeCustomer and reserveInventory already completed, and re-executes only from the sendOrderConfirmation call.

Step 4 - Long-Running Workflows with Timers

Temporal timers are durable, they survive server restarts and worker crashes, firing at the correct time regardless of what happened to the infrastructure while they were waiting:

// src/workflows/subscription-billing.workflow.ts
import { proxyActivities, sleep, continueAsNew } from '@temporalio/workflow';

const { chargeCustomer, sendInvoice, handlePaymentFailure } =
  proxyActivities<typeof BillingActivities>({
    startToCloseTimeout: '30 seconds',
    retry: { maximumAttempts: 3 },
  });

export interface SubscriptionInput {
  customerId: string;
  planId: string;
  monthlyAmount: number;
  billingCycleCount: number;   // how many cycles have run
}

export async function subscriptionBillingWorkflow(
  input: SubscriptionInput,
): Promise<void> {
  const { customerId, planId, monthlyAmount, billingCycleCount } = input;

  // Wait for next billing date — durable 30-day timer
  await sleep('30 days');

  // Attempt billing
  try {
    const result = await chargeCustomer(
      customerId,
      monthlyAmount,
      `${planId}-cycle-${billingCycleCount}`,
    );
    await sendInvoice(customerId, result.transactionId, monthlyAmount);
  } catch (error) {
    await handlePaymentFailure(customerId, billingCycleCount);
  }

  // Temporal event history grows with each cycle — use continueAsNew
  // to reset history while preserving workflow identity
  await continueAsNew<typeof subscriptionBillingWorkflow>({
    customerId,
    planId,
    monthlyAmount,
    billingCycleCount: billingCycleCount + 1,
  });
}
Enter fullscreen mode Exit fullscreen mode

continueAsNew is critical for perpetual workflows, it closes the current workflow history and starts a new execution with fresh history, preserving the workflow ID. Without it, a subscription billing workflow running for years would accumulate an unbounded event history.

A durable sleep('30 days') in Temporal is not a 30-day setTimeout. If the worker is restarted, the server rebooted, or the entire Temporal cluster is upgraded during those 30 days, the timer fires correctly when the time comes. This is qualitatively different from any timer mechanism available in a standard application runtime.

Step 5 - Signals: Communicating with Running Workflows

Signals allow external systems to send events to a running workflow, without polling, without a database flag, without a queue:

// From your NestJS API — send a cancellation signal to a running workflow
import { Client } from '@temporalio/client';
import { cancelOrderSignal } from './workflows/order-fulfillment.workflow';

const client = new Client();

// Cancel a specific order — signal the running workflow directly
async function cancelOrder(orderId: string, reason: string): Promise<void> {
  const handle = client.workflow.getHandle(orderId);  // orderId is the workflow ID
  await handle.signal(cancelOrderSignal, reason);
}

// Query workflow state without interrupting it
async function getOrderStatus(orderId: string): Promise<string> {
  const handle = client.workflow.getHandle(orderId);
  return handle.query(orderStatusQuery);
}
Enter fullscreen mode Exit fullscreen mode

The signal is delivered to the workflow exactly once, durably, even if the workflow is in the middle of a sleep, waiting for an activity, or processing another signal. The workflow receives it on the next evaluation cycle.

Step 6 - Child Workflows for Decomposition

Large workflows decompose into child workflows, independently observable, independently retryable units of orchestration:

// Parent workflow — orchestrates the overall order process
import { executeChild } from '@temporalio/workflow';
import { paymentWorkflow } from './payment.workflow';
import { fulfilmentWorkflow } from './fulfilment.workflow';

export async function orderOrchestrationWorkflow(
  order: Order,
): Promise<void> {
  // Execute payment as a child workflow — has its own retry policy and history
  const paymentResult = await executeChild(paymentWorkflow, {
    args: [{ orderId: order.id, customerId: order.customerId, amount: order.total }],
    workflowId: `payment-${order.id}`,
    taskQueue: 'payment-queue',
  });

  if (paymentResult.status !== 'succeeded') {
    throw new ApplicationFailure('Payment failed', 'PAYMENT_FAILED');
  }

  // Execute fulfilment as a child workflow in parallel for each warehouse
  await Promise.all(
    order.warehouses.map(warehouseId =>
      executeChild(fulfilmentWorkflow, {
        args: [{ orderId: order.id, warehouseId }],
        workflowId: `fulfilment-${order.id}-${warehouseId}`,
        taskQueue: 'fulfilment-queue',
      })
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

Child workflows are independently visible in the Temporal UI, you can observe the payment workflow and the fulfilment workflows as separate entities, each with their own event history, each independently retryable.

Step 7 - Running Workers

// src/worker.ts
import { Worker } from '@temporalio/worker';
import * as PaymentActivities from './activities/payment.activities';
import * as InventoryActivities from './activities/inventory.activities';
import * as NotificationActivities from './activities/notification.activities';

async function runWorker() {
  const worker = await Worker.create({
    workflowsPath: require.resolve('./workflows/order-fulfillment.workflow'),
    activities: {
      ...PaymentActivities,
      ...InventoryActivities,
      ...NotificationActivities,
    },
    taskQueue: 'orders-queue',
    maxConcurrentActivityTaskExecutions: 20,
    maxConcurrentWorkflowTaskExecutions: 10,
  });

  await worker.run();
}

runWorker().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Workers are stateless. Run as many as you need for throughput, Temporal distributes tasks across all available workers automatically. Scale workers horizontally without coordination.

Temporal vs. Traditional Approaches

Problem Traditional Approach Temporal
Retry failed steps Manual retry logic + dead letter queues Configurable retry policy per activity
Long-running timers Cron jobs + database state Durable sleep(), survives restarts
Parallel execution Manual async coordination Promise.all() over activity calls
Cancel in-flight process Poll a database flag Send a signal
Compensating transactions Saga framework or manual rollback Sequential code with try/catch
Observe running processes Query database state Temporal UI + queries
Workflow versioning Schema migrations Built-in versioning API

Common Pitfalls to Avoid

Non-deterministic workflow code. Calling Date.now(), Math.random(), or accessing external state directly in workflow code breaks replay. Use workflow.now() for timestamps, pass random values as workflow inputs, and read external state only through activities.

Blocking in workflows. Never use setTimeout, setInterval, or promise-based delays in workflow code. Use Temporal's sleep(), it is durable, the others are not.

Too much logic in activities. Activities should do one thing, one API call, one database write. Complex coordination logic belongs in the workflow, not the activity. An activity that does five things is an activity that partially fails and leaves unclear what to retry.

Missing idempotency keys on activities. Activities retry. If the activity makes a charge or sends an email, it must include an idempotency key derived from the workflow input, not a randomly generated UUID per attempt.

Unbounded workflow history. Perpetual workflows that never use continueAsNew accumulate event history indefinitely. This degrades performance and eventually hits Temporal's history size limits. Use continueAsNew for any workflow with recurring cycles.

Conclusion

Temporal.io solves a category of problem that traditional queues, cron jobs, and state machines handle poorly, long-running, multi-step business processes that must survive infrastructure failures without losing progress.

The programming model is its defining advantage. You write sequential TypeScript code, charge, reserve, confirm, and Temporal makes it fault-tolerant. No saga framework to configure. No compensating transaction registry to maintain. No polling loops to implement. The workflow code is the specification of the business process, readable by any engineer on the team.

The learning curve is real, the determinism constraint, replay semantics, and continueAsNew pattern require careful understanding. But once internalized, the model eliminates an entire category of distributed systems complexity that most engineering teams carry as permanent operational debt.

Migrating from a queue-based architecture (BullMQ, SQS, RabbitMQ) to Temporal? The migration is incremental, Temporal can consume from existing queues via activities while you migrate orchestration logic workflow by workflow.

Temporal #DurableExecution #WorkflowOrchestration #NodeJS #TypeScript

Top comments (0)