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
}
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)
}
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
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 })
}
}
}
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)
}
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()
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 })
}
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)
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)