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.
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.
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
vs
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
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
The checkout API starts workflow order-1003. The service writes event 1, WorkflowExecutionStarted, with the order as input.
The service schedules a workflow task on the checkout queue. Nothing runs inside the service itself.
A worker polls the queue, runs the workflow function until it reaches an activity call, and replies with a command: schedule chargePayment.
A worker runs the activity: an ordinary function that calls the payment API.
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.
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
vs
Verdict: If it touches the outside world, it's an activity. If it decides what happens next, it's workflow code.
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.
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:
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).
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.
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:
1createShipment, marked attempt 2: the first attempt died with worker 1.
2Ten seconds later, after the start-to-close timeout, worker 2 completes it and sends the confirmation.
3COMPLETED, 11.5 seconds end to end.
The killed-worker run in the Temporal Web UI (local dev server, October 25, 2026)
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.
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
vs
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).
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.
Register cancelOrder, then create the order. Register releaseInventory, then reserve the stock.
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.
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';importtype*as activities from'./activities';importtype{ 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',});exportasyncfunctioncheckout(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));awaitcreateOrder(order); compensations.unshift(()=>releaseInventory(order));awaitreserveInventory(order); compensations.unshift(()=>refundPayment(order));awaitchargePayment(order);}catch(err){awaitcompensate(compensations);throw err;}// The pivot: money has moved. From here we retry forward and never undo.const shipmentId =awaitcreateShipment(order);awaitsendConfirmation(order, shipmentId);return shipmentId;}asyncfunctioncompensate(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{awaitundo();}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:
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.
1chargePayment fails with CardDeclined, a non-retryable error, so no retry.
2refundPayment runs first: compensations run newest first.
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
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
vs
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.
crawlpilot/temporal-checkout-saga: the runnable code (MIT). npm run demo reproduces the three runs; npm test replays recorded histories.
Our runs: Temporal TypeScript SDK 1.24.0 against the local dev server from @temporalio/testing, October 25, 2026. Histories: gateway retry, declined card, killed worker (stack traces removed).