DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Temporal.io: Stop Losing State When Your Server Crashes (Production Guide)

Background jobs are a solved problem until they aren't. A job starts, your server restarts during a deploy, and you're left with half-processed records and no way to resume. Temporal solves this by storing workflow state in a database and replaying from checkpoints.

Here's how it works in practice.

The Problem Temporal Solves

// Without Temporal — fragile
async function processOrder(orderId: string) {
  await chargeCard(orderId)       // ✅
  await fulfillOrder(orderId)     // server crashes here
  await sendConfirmationEmail()  // never runs
  await updateInventory()        // never runs
}
Enter fullscreen mode Exit fullscreen mode

If your server crashes between steps 2 and 3, you have a charged card and no fulfillment. Recovering requires a separate audit job, idempotency keys, and manual intervention.

// With Temporal — durable
export async function processOrderWorkflow(orderId: string) {
  await workflow.executeActivity(chargeCard, orderId)
  await workflow.executeActivity(fulfillOrder, orderId)
  await workflow.executeActivity(sendConfirmationEmail, orderId)
  await workflow.executeActivity(updateInventory, orderId)
}
Enter fullscreen mode Exit fullscreen mode

If the worker crashes between steps 2 and 3, Temporal replays from the last checkpoint. No duplicate charges, no lost state.

Setup

# Run Temporal server locally
docker run -d -p 7233:7233 temporalio/auto-setup:latest

# Install SDK
npm install @temporalio/worker @temporalio/client @temporalio/workflow
Enter fullscreen mode Exit fullscreen mode

Defining Activities

Activities are the actual side effects — API calls, DB writes, emails.

// src/activities/orderActivities.ts
import type { ActivityInterface } from "@temporalio/activity"

export interface OrderActivities {
  chargeCard(orderId: string): Promise<void>
  fulfillOrder(orderId: string): Promise<void>
  sendConfirmationEmail(orderId: string): Promise<void>
  updateInventory(orderId: string): Promise<void>
}

export const orderActivities: OrderActivities = {
  async chargeCard(orderId) {
    const order = await db.orders.findById(orderId)
    await stripe.charges.create({
      amount: order.amount,
      currency: "usd",
      customer: order.stripeCustomerId
    })
    await db.orders.update({ id: orderId, data: { charged: true } })
  },

  async fulfillOrder(orderId) {
    await fulfillmentApi.processOrder(orderId)
    await db.orders.update({ id: orderId, data: { fulfilled: true } })
  },

  async sendConfirmationEmail(orderId) {
    const order = await db.orders.findById(orderId)
    await resend.emails.send({
      to: order.customerEmail,
      subject: "Order confirmed",
      react: OrderConfirmation({ orderId })
    })
  },

  async updateInventory(orderId) {
    const items = await db.orderItems.findByOrder(orderId)
    for (const item of items) {
      await db.inventory.decrement({ productId: item.productId, qty: item.quantity })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Defining the Workflow

Workflows are deterministic functions that orchestrate activities. No direct I/O — everything goes through activities.

// src/workflows/processOrder.ts
import { proxyActivities } from "@temporalio/workflow"
import type { OrderActivities } from "../activities/orderActivities"

const { chargeCard, fulfillOrder, sendConfirmationEmail, updateInventory } =
  proxyActivities<OrderActivities>({
    startToCloseTimeout: "5 minutes",
    retry: {
      maximumAttempts: 3,
      initialInterval: "1 second",
      backoffCoefficient: 2
    }
  })

export async function processOrderWorkflow(orderId: string): Promise<void> {
  await chargeCard(orderId)
  await fulfillOrder(orderId)
  await sendConfirmationEmail(orderId)
  await updateInventory(orderId)
}
Enter fullscreen mode Exit fullscreen mode

Each activity gets automatic retry with exponential backoff. If chargeCard fails 3 times, the workflow fails — it doesn't silently skip.

Running the Worker

// src/worker.ts
import { Worker } from "@temporalio/worker"
import { orderActivities } from "./activities/orderActivities"

const worker = await Worker.create({
  workflowsPath: require.resolve("./workflows/processOrder"),
  activities: orderActivities,
  taskQueue: "orders"
})

await worker.run()
Enter fullscreen mode Exit fullscreen mode

Workers can be scaled horizontally. Multiple workers can pick up tasks from the same queue.

Triggering From Your API

// app/api/checkout/route.ts
import { Client, Connection } from "@temporalio/client"
import { processOrderWorkflow } from "@/workflows/processOrder"

const connection = await Connection.connect({ address: "localhost:7233" })
const client = new Client({ connection })

export async function POST(req: Request) {
  const { orderId } = await req.json()

  const handle = await client.workflow.start(processOrderWorkflow, {
    args: [orderId],
    taskQueue: "orders",
    workflowId: `order-${orderId}`  // idempotent — same ID = same workflow
  })

  return Response.json({ workflowId: handle.workflowId })
}
Enter fullscreen mode Exit fullscreen mode

The workflowId makes this idempotent. If the checkout endpoint is called twice with the same orderId, Temporal deduplicates — the workflow runs once.

Signals and Queries

Workflows can receive signals mid-execution:

import { defineSignal, setHandler } from "@temporalio/workflow"

const cancelOrder = defineSignal("cancelOrder")

export async function processOrderWorkflow(orderId: string) {
  let cancelled = false
  setHandler(cancelOrder, () => { cancelled = true })

  await chargeCard(orderId)

  if (cancelled) {
    await refundCard(orderId)
    return
  }

  await fulfillOrder(orderId)
  // ...
}

// From your API — send a signal to a running workflow
await client.workflow.getHandle(`order-${orderId}`).signal(cancelOrder)
Enter fullscreen mode Exit fullscreen mode

When Temporal Is Worth the Overhead

Use Temporal for:

  • Multi-step processes where partial completion = real damage (payments, fulfillment)
  • Long-running jobs (hours/days) that must survive deploys
  • Human-approval workflows with timeouts
  • Saga patterns across microservices

Skip Temporal for:

  • Simple fire-and-forget jobs (use BullMQ)
  • Jobs that are naturally idempotent and fast (<1 minute)
  • Solo dev projects where operational complexity isn't worth it

The Temporal server adds an infrastructure dependency. For small projects, BullMQ with Redis is simpler. Temporal's value compounds as your workflow complexity grows.


Building a SaaS with complex async workflows? The AI SaaS Starter Kit ships with a production-ready background job setup and Stripe billing — get to production in a weekend instead of two weeks.

Top comments (0)