Temporal Explained: Durable Workflows, When to Use Them, and a Saga You Can Crash

What Temporal is, how its event history and replay let a workflow survive a crashed worker, where it fits and where it doesn't, and a checkout saga in TypeScript, recorded from real runs: a flaky gateway, a declined card and a killed worker.

Rahul Bisht

Founder, CrawlPilot

·
Oct 25, 2026
·
Verified Oct 25, 2026
·Engineering·
18 min read
·
Temporal Explained: Durable Workflows, When to Use Them, and a Saga You Can Crash

The worker process was killed 0.9 seconds into the checkout, after the card was charged and while the shipment was being created. About eleven seconds after the checkout began, a second worker process had created the shipment and sent the confirmation. It did not charge the card again, and nobody wrote recovery code for it.

That run, and the two below it, happened on a local Temporal server on October 25, 2026, using the code in this post. Each recorded history is linked from the walkthrough below. This is what Temporal is for: work that spans several services and has to finish even when the process running it doesn't.

Key takeaway
Temporal never runs your code. It keeps an event history of every step a workflow took, and your own workers run the code. When a worker dies, another one replays the history through the same code: finished steps return their recorded results, and the workflow resumes at the first step with none. That makes a saga, steps with compensations, short to write: the undo logic sits next to the steps, in plain code.

This post builds on two others. The saga pattern explains choreography, orchestration and the pivot step. The durable agent runtime design builds the same machinery from scratch. Here we use the real thing.

Keeping a Checkout Alive by Hand

A checkout touches several systems: create the order, reserve stock, charge the card, book the shipment, email the customer. Any process running that sequence can die between two steps. The usual fix grows one piece at a time: a status column, a cron job that finds stuck orders, a retry loop, a flag that stops the cron job and the retry loop from charging twice.

Hand-rolled recovery
  • Retry and backoff rules copied into every step
  • A crash between the call and the status write repeats the call
  • The flow lives in three places: handler, cron job, status table
  • A status column per step, updated after each call
  • A cron job that finds orders stuck in a status
A Temporal workflow
  • Every step's result is recorded before the code moves on
  • Retries, backoff and timeouts are policy, not code
  • A new worker resumes a dead worker's run by replaying it
  • The flow is one function, read top to bottom

Verdict: Temporal doesn't remove the need for idempotent steps; it removes the bookkeeping around them.

The same checkout, kept alive by hand or by Temporal

What Temporal Is

Temporal started in 2019, founded by Samar Abbas and Maxim Fateev, who had built Cadence at Uber, which the company calls Temporal's direct predecessor (Temporal). It's open source, runs self-hosted or as Temporal Cloud, and has official SDKs for Go, Java, Python, TypeScript, .NET, Ruby, PHP and Rust (Temporal docs).

There are three moving parts. Your application asks the Temporal Service to start a workflow. The service stores the workflow's event history and puts tasks on a task queue. Workers, processes you deploy, poll that queue, run your code and report back what happened.

Who does what in one Temporal workflow
YOUR CODETEMPORAL SERVICEOUTSIDE WORLDstart checkout1task2poll3charge4result5Checkout APIstarts the workflowEvent historyevery step recordedTask queue"checkout"Workerruns your codePayment APIa real side effect
  1. The checkout API starts workflow order-1003. The service writes event 1, WorkflowExecutionStarted, with the order as input.
  2. The service schedules a workflow task on the checkout queue. Nothing runs inside the service itself.
  3. A worker polls the queue, runs the workflow function until it reaches an activity call, and replies with a command: schedule chargePayment.
  4. A worker runs the activity: an ordinary function that calls the payment API.
  5. The result is written to the history before the workflow moves on. That record is what lets another worker pick up from here.

5 steps. Press Play to walk through them, or click any part of the diagram.

Your app starts a run; the Temporal Service records it and queues work; your worker runs the code and the activity calls the outside world.

Workflows Decide, Activities Act

Temporal splits your code in two, and the split is the whole trick.

A workflow is the orchestration: call this, then that, and if this fails, do the other thing. It must be deterministic, because a new worker re-runs it against the recorded history and it has to make the same choices again. The TypeScript SDK runs workflows in a sandbox where Math.random(), Date and setTimeout() are replaced by deterministic versions, and workflow code can't import your activity code, only its types (Temporal docs).

An activity is a normal function that touches the world: an HTTP call, a database write, an email. It can fail, time out and be retried. Activities retry by default; workflows don't (Temporal docs).

Workflow code
  • Calls activities, timers and child workflows
  • Branches, loops and try/catch
  • Waits for signals, such as a person's approval
  • No network, disk or database calls
  • No reading the real clock or real randomness
Activity code
  • Any I/O: APIs, databases, files, email
  • Retried on failure, per its retry policy
  • Can heartbeat while doing long work
  • May run more than once, so it must be idempotent

Verdict: If it touches the outside world, it's an activity. If it decides what happens next, it's workflow code.

What belongs in each half

Crash It: Event History and Replay

Here is the workflow we ran, the checkout saga built step by step later in this post, next to its real event histories. Pick Worker killed to see the run from the opening.

1export async function checkout(order: Order) {
2 const compensations = [];
3 try {
4 compensations.unshift(() => cancelOrder(order));
5 await createOrder(order);
6 compensations.unshift(() => releaseInventory(order));
7 await reserveInventory(order);
8 compensations.unshift(() => refundPayment(order));
9 await chargePayment(order);
10 } catch (err) {
11 await compensate(compensations); // newest first
12 throw err;
13 }
14 // Past the pivot: retry forward, never undo.
15 const shipmentId = await createShipment(order);
16 await sendConfirmation(order, shipmentId);
17 return shipmentId;
18}
Event history · order-100335/35
  1. 1Workflow Started0 ms
  2. 2WorkflowTask Scheduled0 ms
  3. 3WorkflowTask Startedworker 112 ms
  4. 4WorkflowTask Completedworker 1138 ms
  5. 5Activity ScheduledcreateOrder138 ms
  6. 6Activity StartedcreateOrderworker 1139 ms
  7. 7Activity CompletedcreateOrderworker 1159 ms
  8. 8WorkflowTask Scheduled159 ms
  9. 9WorkflowTask Startedworker 1162 ms
  10. 10WorkflowTask Completedworker 1186 ms
  11. 11Activity ScheduledreserveInventory186 ms
  12. 12Activity StartedreserveInventoryworker 1186 ms
  13. 13Activity CompletedreserveInventoryworker 1198 ms
  14. 14WorkflowTask Scheduled198 ms
  15. 15WorkflowTask Startedworker 1202 ms
  16. 16WorkflowTask Completedworker 1214 ms
  17. 17Activity ScheduledchargePayment214 ms
  18. 18Activity StartedchargePaymentworker 1215 ms
  19. 19Activity CompletedchargePaymentworker 1223 ms
  20. 20WorkflowTask Scheduled223 ms
  21. 21WorkflowTask Startedworker 1226 ms
  22. 22WorkflowTask Completedworker 1245 ms
  23. 23Activity ScheduledcreateShipment245 ms
  24. Worker 1 killed, about 0.9 s in. No event is written.
  25. 24Activity StartedcreateShipmentattempt 2worker 211.25 slast failure: activity StartToClose timeout
  26. 25Activity CompletedcreateShipmentworker 211.28 s
  27. 26WorkflowTask Scheduled11.28 s
  28. 27WorkflowTask Startedworker 211.28 s
  29. 28WorkflowTask Completedworker 211.45 s
  30. 29Activity ScheduledsendConfirmation11.45 s
  31. 30Activity StartedsendConfirmationworker 211.45 s
  32. 31Activity CompletedsendConfirmationworker 211.46 s
  33. 32WorkflowTask Scheduled11.46 s
  34. 33WorkflowTask Startedworker 211.46 s
  35. 34WorkflowTask Completedworker 211.47 s
  36. 35Workflow Completed11.47 s
2 worker processes · completed at 11.5 s Full history (JSON)

The worker process is killed while the shipment is being created. A second worker picks the run up and finishes it; nothing is charged twice. Press Play to walk the 35 recorded events, or use the arrows.

Three real runs of the checkout workflow (Temporal TypeScript SDK 1.24.0, local dev server, October 25, 2026). Event ids, attempts, failures and times are as recorded.

Three things in that history are worth noticing:

  1. 01
    A running attempt leaves no trace. Worker 1 started the shipment, then died. The history shows only that the activity was scheduled (event 23), and the server learned about the crash only when the 10-second start-to-close timeout ran out. Temporal's docs recommend always setting that timeout for exactly this reason: the server "doesn't detect failures when a Worker loses communication with the Server or crashes" (Temporal docs).
  2. 02
    Retries don't add events. Event 24 is attempt 2, carrying the last failure ("activity StartToClose timeout") as an attribute. In the gateway run, the charge failed twice with a 503 and the history holds one started event: attempt 3.
  3. 03
    Worker 2 rebuilt the workflow from the history. It had never seen order-1003. To decide what came after the shipment, it ran the workflow function from the top. createOrder, reserveInventory and chargePayment returned their recorded results, so the card wasn't charged again.

The Temporal Web UI shows the same run. The shipment bar runs from the moment worker 1 started it to the moment worker 2 finished it:

Temporal Web UI timeline for order-1003: createOrder, reserveInventory and chargePayment complete quickly, then createShipment shows attempt 2 with a bar of about ten seconds, then sendConfirmation
  1. 1createShipment, marked attempt 2: the first attempt died with worker 1.
  2. 2Ten seconds later, after the start-to-close timeout, worker 2 completes it and sends the confirmation.
  3. 3COMPLETED, 11.5 seconds end to end.
The killed-worker run in the Temporal Web UI (local dev server, October 25, 2026)

Retries and Timeouts Are Policy

Every activity call comes with a retry policy. Temporal's defaults are a 1-second initial interval, a backoff coefficient of 2, a maximum interval of 100 times the initial interval, and unlimited attempts, with no error types marked non-retryable (Temporal docs). Each attempt also needs a start-to-close timeout or a schedule-to-close timeout; set neither and the service rejects the activity (Temporal docs).

The question that matters in an outage is how long your work sits idle after the dependency comes back:

300 s
1.0 s
2.0
100 s
Attempts during the outage
9
First attempt after recovery
5.5 min in
Idle after it's back
27 s
Outage5.0 min
Until the retry that succeeds5.5 min

Retries slow down quickly at first, then hold at the maximum interval, so recovery is noticed within one interval.

Defaults are Temporal's documented activity retry policy: 1 s initial interval, backoff coefficient 2, maximum interval 100 × the initial interval, unlimited attempts. Treats each attempt as instant, so add your start-to-close time per attempt, and assumes the outage begins with the first attempt.

Temporal's default retry policy against an outage you choose (attempts treated as instant)

With the defaults and a five-minute outage, the activity tries nine times, then waits up to 100 seconds between attempts, so it notices the recovery 27 seconds late. Lower the maximum interval if that matters more than the extra calls.

Two details from our code make retries safe rather than dangerous:

  • A business "no" isn't retried. A declined card won't succeed on attempt 50. The activity throws ApplicationFailure.nonRetryable(..., 'CardDeclined'), and the policy also lists CardDeclined in nonRetryableErrorTypes, so the workflow sees the failure at once.
  • Every side effect has an idempotency key. Retries are at-least-once. The charge passes the order id as its key, so a retry after a timeout can't charge twice. It's the same idea as idempotency keys in Redis or a payment API.

Where Temporal Fits, and Where It Doesn't

Good fits
  • Orders, payments and refunds across several services
  • Onboarding or provisioning that waits hours or days
  • Human approval steps, raced against a timer
  • AI agents and long tool-calling runs
  • Batch jobs whose steps must finish exactly once
Poor fits
  • One fast request and response: a workflow adds a round trip
  • Stream processing with windows and aggregates
  • A plain cron job with no state between runs
  • Teams not ready to run, or pay for, one more service

Verdict: Reach for Temporal when the hard part is finishing, not computing.

Good and poor fits for a durable workflow engine

For streams of events, a stream processor is the better tool. See how Apache Flink keeps state and the ad click aggregator design. For agents, durable execution is the same machinery this durable runtime design builds by hand. Temporal names its own users, among them Netflix (150+ engineering teams and 500+ namespaces, by Temporal's account), OpenAI, NVIDIA and Booking.com (Temporal).

The Saga in Temporal

A saga runs a sequence of local steps and undoes the finished ones when a later step fails. In the saga post's terms, a Temporal workflow is an orchestrator, and it's a short one, because the workflow's own state lives in the event history.

The checkout saga and its pivot
12money moved33Create orderundo: cancelReserve stockundo: releaseCharge cardthe pivotCreate shipmentretry forwardConfirmationretry forward
  1. Register cancelOrder, then create the order. Register releaseInventory, then reserve the stock.
  2. Register refundPayment, then charge. If the charge fails for good, run the undo list newest first: refund (a no-op if nothing was charged), release, cancel.
  3. Once the charge succeeds, there's no going back: shipment and confirmation are retried until they succeed, never rolled back.

3 steps. Press Play to walk through them, or click any part of the diagram.

Create, reserve and charge each register an undo first; once the charge succeeds, the remaining steps are retried forward, never undone.

Here is the workflow from the runs above, unedited. The whole project, with the activities, a worker, a one-command demo of all three runs and replay tests, is on GitHub: crawlpilot/temporal-checkout-saga.

ts
import { CancellationScope, log, proxyActivities } from '@temporalio/workflow'; import type * as activities from './activities'; import type { Order } from './activities'; // Forward steps: retry transient failures with backoff; a business "no" fails at once. const { createOrder, reserveInventory, chargePayment, createShipment, sendConfirmation } = proxyActivities<typeof activities>({ startToCloseTimeout: '10 seconds', retry: { initialInterval: '1 second', backoffCoefficient: 2, maximumInterval: '1 minute', nonRetryableErrorTypes: ['CardDeclined', 'OutOfStock'], }, }); // Compensations: never give up (the default retry policy has no attempt limit). const { cancelOrder, releaseInventory, refundPayment } = proxyActivities<typeof activities>({ startToCloseTimeout: '10 seconds', }); export async function checkout(order: Order): Promise<string> { const compensations: Array<() => Promise<void>> = []; try { // Register each undo before its step: a step can fail after it took effect. compensations.unshift(() => cancelOrder(order)); await createOrder(order); compensations.unshift(() => releaseInventory(order)); await reserveInventory(order); compensations.unshift(() => refundPayment(order)); await chargePayment(order); } catch (err) { await compensate(compensations); throw err; } // The pivot: money has moved. From here we retry forward and never undo. const shipmentId = await createShipment(order); await sendConfirmation(order, shipmentId); return shipmentId; } async function compensate(compensations: Array<() => Promise<void>>): Promise<void> { // Run even if the workflow is being cancelled, newest first, and keep going past a failure. await CancellationScope.nonCancellable(async () => { for (const undo of compensations) { try { await undo(); } catch (err) { log.error('compensation failed', { error: String(err) }); } } }); }

Each choice in it comes from Temporal's saga guidance or the SDK docs:

  • Register before the step. The docs call this "the safer default when Activities have side effects that may occur before failure": a charge can go through and its response still time out. The cost is that every compensation must handle a step that never ran. Ours do: refundPayment refunds only a charge made under the order's key, so it's a no-op when nothing was charged.
  • Newest first. unshift keeps the list in reverse order, so the last step done is the first one undone.
  • Compensations keep trying. They use a separate retry policy with no attempt limit. If one still fails, the loop logs it and carries on, as the docs advise.
  • Cancellation doesn't stop the undo. If someone cancels the workflow mid-charge, the catch block still has to clean up. The TypeScript docs say "cleanup logic must be in a nonCancellable scope" (Temporal docs).
  • Rethrow. The workflow fails with the original error, so its status tells the truth.

Pick Card declined to watch the undo list run against the real history:

1export async function checkout(order: Order) {
2 const compensations = [];
3 try {
4 compensations.unshift(() => cancelOrder(order));
5 await createOrder(order);
6 compensations.unshift(() => releaseInventory(order));
7 await reserveInventory(order);
8 compensations.unshift(() => refundPayment(order));
9 await chargePayment(order);
10 } catch (err) {
11 await compensate(compensations); // newest first
12 throw err;
13 }
14 // Past the pivot: retry forward, never undo.
15 const shipmentId = await createShipment(order);
16 await sendConfirmation(order, shipmentId);
17 return shipmentId;
18}
Event history · order-100241/41
  1. 1Workflow Started0 ms
  2. 2WorkflowTask Scheduled1 ms
  3. 3WorkflowTask Startedworker 16 ms
  4. 4WorkflowTask Completedworker 124 ms
  5. 5Activity ScheduledcreateOrder25 ms
  6. 6Activity StartedcreateOrderworker 125 ms
  7. 7Activity CompletedcreateOrderworker 133 ms
  8. 8WorkflowTask Scheduled33 ms
  9. 9WorkflowTask Startedworker 137 ms
  10. 10WorkflowTask Completedworker 151 ms
  11. 11Activity ScheduledreserveInventory51 ms
  12. 12Activity StartedreserveInventoryworker 151 ms
  13. 13Activity CompletedreserveInventoryworker 162 ms
  14. 14WorkflowTask Scheduled62 ms
  15. 15WorkflowTask Startedworker 166 ms
  16. 16WorkflowTask Completedworker 184 ms
  17. 17Activity ScheduledchargePayment84 ms
  18. 18Activity StartedchargePaymentworker 184 ms
  19. 19Activity FailedchargePaymentworker 195 msfailure: card declined: insufficient funds
  20. 20WorkflowTask Scheduled95 ms
  21. 21WorkflowTask Startedworker 1100 ms
  22. 22WorkflowTask Completedworker 1128 ms
  23. 23Activity ScheduledrefundPayment128 ms
  24. 24Activity StartedrefundPaymentworker 1128 ms
  25. 25Activity CompletedrefundPaymentworker 1134 ms
  26. 26WorkflowTask Scheduled134 ms
  27. 27WorkflowTask Startedworker 1138 ms
  28. 28WorkflowTask Completedworker 1150 ms
  29. 29Activity ScheduledreleaseInventory150 ms
  30. 30Activity StartedreleaseInventoryworker 1151 ms
  31. 31Activity CompletedreleaseInventoryworker 1157 ms
  32. 32WorkflowTask Scheduled157 ms
  33. 33WorkflowTask Startedworker 1162 ms
  34. 34WorkflowTask Completedworker 1177 ms
  35. 35Activity ScheduledcancelOrder177 ms
  36. 36Activity StartedcancelOrderworker 1177 ms
  37. 37Activity CompletedcancelOrderworker 1184 ms
  38. 38WorkflowTask Scheduled184 ms
  39. 39WorkflowTask Startedworker 1187 ms
  40. 40WorkflowTask Completedworker 1205 ms
  41. 41Workflow Failed205 msfailure: card declined: insufficient funds
1 worker process · failed at 0.2 s Full history (JSON)

The card is declined, a non-retryable error. The saga runs its three compensations, newest first, and the workflow fails. Press Play to walk the 41 recorded events, or use the arrows.

The declined-card run, recorded from the workflow above. The three compensations run in reverse order of registration.
Temporal Web UI timeline for order-1002: createOrder and reserveInventory succeed, chargePayment fails in red, then refundPayment, releaseInventory and cancelOrder run
  1. 1chargePayment fails with CardDeclined, a non-retryable error, so no retry.
  2. 2refundPayment runs first: compensations run newest first.
  3. 3Then releaseInventory and cancelOrder, and the workflow fails with the original error.
The declined-card run in the Temporal Web UI, about 200 ms end to end

The Parts That Bite in Production

Changing code under running workflows. Replay expects your code to make the same calls in the same order. Add, remove or reorder an activity call and deploy it, and runs that started on the old code fail with a nondeterminism error. In TypeScript you wrap the change in patched('change-id'), which records a marker in the history so old runs take the old branch; later you switch to deprecatePatch() and finally remove both. Worker Versioning is the other route: tag workers by deployment version and pin runs to them (Temporal docs). The example repo's replay test shows both sides: add an activity call before the charge and npm test fails with Temporal's [TMPRL1100] Nondeterminism error; wrap the call in patched() and it passes.

Deploy the change as is
  • Runs already past reserve replay into a call the history doesn't have
  • They stop with a nondeterminism error
Wrap it in patched()
  • New runs record the marker and do the check
  • Old runs find no marker and skip it
  • Later: deprecatePatch(), then remove both

Verdict: Any change to which activities run, or in what order, needs a patch or a new worker version.

Adding a fraud check between reserve and charge

Histories have a ceiling. One run's history is capped at 51,200 events or 50 MB, and you get warnings from 10,240 events or 10 MB (Temporal docs). A workflow that loops forever, such as a subscription that bills monthly, calls continue-as-new: same workflow id, a new run with a fresh history. A long history also costs time on every crash, since a new worker has to fetch and replay all of it.

Compensation isn't rollback. A refund is a new transaction the customer sees, not an erased charge. Steps past the pivot shouldn't be compensated at all. If a shipment really can't be created, that's a decision for a person, which a workflow can wait for as a signal, and the human approval section of the durable runtime post shows how that's raced against a timer.

Idempotency is still your job. Temporal records results exactly once, but runs activities at least once. Every activity that changes something outside needs a key the other side respects.

Check Yourself

Check yourself

Q1Why does the checkout register refundPayment before calling chargePayment, not after?

Q2Worker 1 is killed mid-shipment. Why doesn't worker 2 charge the card again?

Cheat sheet
Temporal and the saga pattern

The model

  • The service records an event history; your workers run the code.
  • A new worker replays the history: recorded steps return their results.

Workflows and activities

  • Workflows decide and must be deterministic; activities do the I/O.
  • Activities retry by default (1 s, ×2, max 100 × initial, unlimited); workflows don't.
  • Always set a start-to-close timeout: it's how a dead worker gets noticed.

The saga

  • Register each compensation before its step; make it idempotent.
  • On a non-retryable failure, undo newest first, in a nonCancellable scope.
  • Past the pivot (the charge), retry forward; never undo.

In production

  • Change activity calls only behind patched() or a new worker version.
  • Keep histories under 51,200 events or 50 MB; use continue-as-new.
  • Retries are at-least-once: every side effect needs an idempotency key.

Sources