Temporal and Workflow Orchestration: Durable Execution for Engineers Who've Been Burned
Every engineer who has built a multi-step distributed process has the same scar tissue. An order processing flow that touches five services. A user onboarding pipeline that sends emails, provisions accounts, and calls three external APIs. A background job that needs to run for two minutes, retry on failure, and report completion back to the original request. You bolt it together with a task queue, add retry logic, write state to a database to track progress, and ship it. Six months later a partial failure leaves a user half-onboarded. A network blip causes a payment to be charged twice because the idempotency logic had a race condition. A queue worker crashes mid-job and the job silently disappears.
The pattern of problems is the same everywhere: distributed state is hard, failure handling is accidental rather than structural, and the coordination logic ends up smeared across application code, database schema, and queue configuration in ways that are impossible to test.
Temporal is a durable execution engine. The central claim is: write your business logic as regular code — functions calling functions — and Temporal guarantees it runs to completion even if every machine involved crashes and reboots, even if the process takes months, even if you redeploy in the middle of execution. It does this not through magic but through a specific architectural choice: every state transition is appended to a durable event log, and execution is always recoverable by replaying that log.
This post covers how the durable execution model works mechanically, workflows and activities in Go and Python with real examples, the retry and timeout system, the determinism constraints that bite new users, self-hosting Temporal on Kubernetes, and an honest comparison with the alternatives you might already be using.
The Core Problem with Task Queues
A task queue (Celery, Sidekiq, RQ, SQS workers) solves one problem well: defer a unit of work to a background process. It solves this problem in isolation. What it does not solve — and what engineers build around with escalating complexity — is coordinated multi-step execution with durable state.
Consider a user signup flow:
1. Create user record in database
2. Send welcome email
3. Provision workspace (calls external API, may take 30s)
4. Send Slack notification to #new-signups
5. Trigger trial period scheduler (needs to fire in 14 days)
With a task queue:
- Each step is a separate task
- You track progress in a database table (
signup_state, with columns for each step’s completion) - Each task checks the state table, does its work, updates state
- Failures retry the individual task — but if step 3 fails after step 2 completes, you need idempotency logic to avoid sending the welcome email again
- If you want to wait 14 days and then do something, you need a separate scheduled job system
- If you want to pause the workflow and wait for a human to approve something, you need another database table and a polling mechanism
The coordination logic — the state machine, the idempotency checks, the scheduler integration — is yours to build and maintain. It is never in one place, and it is rarely tested as a unit.
Temporal’s answer: the workflow is the coordination logic, written as ordinary code, and Temporal makes it durable. You do not write a state machine — you write the happy path, and Temporal handles everything else.
How Durable Execution Works
Temporal persists every state transition as an entry in an append-only event history. When an activity completes, that completion is recorded. When a timer fires, that firing is recorded. When a signal arrives, that signal is recorded. The workflow’s current state is always fully reconstructable from its event history.
When a Worker crashes mid-execution — or you redeploy, or the server restarts — Temporal does not try to resume from a checkpoint. It replays the event history from the beginning. The Workflow code runs again from the top, but instead of re-executing activities, it reads their recorded results from the history. The workflow function deterministically reaches the same state it was in before the crash. When replay reaches the point in history where execution was interrupted, it continues forward with new work.
Event History for workflow "order-12345":
┌────────────────────────────────────────────────────────┐
│ 1. WorkflowExecutionStarted {input: {order_id: 12345}}│
│ 2. ActivityTaskScheduled {activity: "charge_card"} │
│ 3. ActivityTaskStarted │
│ 4. ActivityTaskCompleted {result: {charge_id: "x"}}│
│ 5. ActivityTaskScheduled {activity: "ship_order"} │
│ 6. ActivityTaskStarted │
│ ← WORKER CRASHED HERE │
└────────────────────────────────────────────────────────┘
On recovery, Temporal replays events 1-6.
The workflow code runs again:
- charge_card() → history says it already completed, return recorded result
- ship_order() → history says it started but never completed → re-execute
This is why Temporal workflows have a determinism requirement: the code must produce the same sequence of commands on every replay. If replay produces a different sequence of events than what is in the history, there is a non-determinism error and the workflow cannot recover. More on this constraint shortly.
The practical consequence: you do not need to write idempotency logic into your workflow code. Temporal handles it structurally. An activity that already completed in the history will never be re-executed on replay — its recorded result is returned directly.
Workflows and Activities
The fundamental distinction in Temporal:
Workflows are the coordinators. They orchestrate activities, wait on timers, handle signals, and express the business logic of what should happen and in what order. They must be deterministic — no external calls, no random numbers, no reading the current time directly.
Activities are the workers. They do the actual work: call external APIs, write to databases, send emails, process files. They are allowed to fail, and they are retried automatically. They are not deterministic and do not need to be.
Go example: order processing
|
|
|
|
|
|
|
|
Python example
|
|
|
|
|
|
Retries and Timeouts
Temporal’s retry and timeout system is the most complete in any workflow engine, and understanding the distinct timeout types is critical for correct behavior.
Timeout types
Activity timeouts (apply to a single Activity Execution):
┌──────────────────────────────────────────────────────────────────────┐
│ Schedule-to-Start: how long to wait for a worker to pick up the task │
│ (catches: no workers running, queue backed up) │
│ │
│ Start-to-Close: how long the activity can run once started │
│ (catches: activity hanging, infinite loop, slow external call) │
│ THIS IS THE MOST IMPORTANT ONE — always set it │
│ │
│ Schedule-to-Close: total time budget for the whole activity attempt │
│ including retries — a hard ceiling │
│ │
│ Heartbeat timeout: if activity sends heartbeats, max gap between them│
│ (catches: worker crash mid-activity; requires RecordHeartbeat()) │
└──────────────────────────────────────────────────────────────────────┘
Workflow timeouts (apply to the entire Workflow Execution):
┌──────────────────────────────────────────────────────────────────────┐
│ Execution timeout: total clock time the workflow is allowed to run │
│ including all retries and waits │
│ │
│ Run timeout: maximum duration of a single run (between ContinueAsNew)│
│ │
│ Task timeout: maximum time a workflow task can run on a worker │
│ (default 10s; increase for compute-intensive workflows) │
└──────────────────────────────────────────────────────────────────────┘
The retry policy
|
|
By default, activities have unlimited retries with exponential backoff. This is intentionally aggressive: Temporal’s philosophy is that transient failures (network issues, rate limits, downstream service blips) should be automatically handled and not require human intervention. You opt out by setting NonRetryableErrorTypes for errors that represent permanent failures.
Signals: external input to running workflows
A running workflow can receive signals — external events that trigger a change in workflow state. This replaces the pattern of polling a database table or using a message queue to communicate with a running process:
|
|
The workflow suspends at Receive() — no polling, no database writes, no resources consumed while waiting. When the signal arrives (minutes, hours, or days later), Temporal wakes a worker, replays the history to the suspension point, delivers the signal, and execution continues.
The Determinism Constraint
This is the part that surprises engineers new to Temporal. Because workflows are recovered by replaying event history, the workflow code must produce the exact same sequence of commands on every replay. Anything that introduces nondeterminism — random numbers, current time, direct external calls — breaks replay.
What you cannot do in workflow code:
|
|
What you do instead:
|
|
The verifier at workflow startup does not enforce these rules statically — violations only surface as non-determinism errors during replay, which means they often appear in production under failure conditions. The rule of thumb: if it touches the outside world or produces a value that could differ between runs, put it in an Activity.
Code changes and versioning
Changing workflow code while instances are running requires care. Old instances have an event history produced by the old code. If you change the sequence of activities in a workflow and deploy, in-flight workflows will fail on replay because the new code produces a different command sequence than the history.
The solution is workflow.GetVersion() (Go) or workflow.patched() (Python):
|
|
Old instances replay with v == workflow.DefaultVersion and skip the new activity. New instances and new replays get v == 1 and include it. After all old instances complete, you clean up the version check.
Self-Hosting on Kubernetes
Temporal’s server is composed of four services: Frontend (API gateway, gRPC), History (event history storage and workflow state), Matching (task queue management, routes tasks to workers), and Worker (internal background tasks). They can run as separate Deployments or collapsed into a single binary for smaller deployments.
The official Helm chart is the recommended path for Kubernetes:
|
|
|
|
The History service is the scaling bottleneck. It manages shards — each shard handles a subset of workflow executions. The default is 512 shards per cluster. For larger deployments, horizontal scaling of History replicas allows concurrent processing across shards. The database (PostgreSQL or Cassandra) is the actual persistence layer; Temporal strongly recommends Cassandra for large-scale deployments but PostgreSQL is well-supported and adequate for most self-hosted cases.
For local development, the docker-compose approach is simplest:
|
|
Connect workers to localhost:7233. The UI at localhost:8080 shows all running workflows, their histories, and their current state. This view alone is worth the setup — seeing the event history for a failed workflow in the UI immediately tells you exactly which activity failed, what error it returned, and how many retries it consumed.
Temporal vs. the Alternatives
The workflow orchestration space is crowded and the tools serve genuinely different problems:
| Temporal | Celery | Airflow | AWS Step Functions | |
|---|---|---|---|---|
| Primary model | Durable code execution | Distributed task queue | DAG-based batch scheduling | State machine (JSON config) |
| Workflow as code | Yes — full language | Partial (task chains) | No (DAG definitions) | No (ASL JSON) |
| Long-running workflows | Yes (years) | Fragile | Fragile (DAGs restart) | Yes (up to 1 year) |
| Arbitrary waits/timers | Yes (free — no polling) | No | No (sensor tasks consume resources) | Yes |
| Signals / human approval | Built-in | Manual (database + polling) | Manual | Callback tokens |
| Retry policy | Rich (per-activity, backoff, non-retryable types) | Basic | Basic | Per-state, limited |
| Replay/determinism | Yes (required) | No | No | No |
| Versioning | GetVersion/patched API | N/A | N/A | N/A |
| Self-hosted | Yes (complex) | Yes (simple) | Yes (complex) | No (AWS only) |
| Local dev experience | Good (docker-compose) | Good | Moderate | Poor (LocalStack) |
| Best for | Business process orchestration | Async task execution in Python apps | Batch data pipelines, ETL | AWS-native serverless workflows |
Celery is the right choice when you have a Python application that needs to defer work to background processes and the coordination is simple (fan-out, simple chaining). The operational overhead is low, the ecosystem is mature, and the mental model maps to what most Python developers already know. Temporal adds significant complexity that Celery-appropriate workloads do not justify.
Airflow is the right choice for scheduled batch data pipelines. Its 60+ provider integrations (Snowflake, dbt, BigQuery, S3, Spark) and mature scheduler make it the default for data engineering. It is not designed for the “process a single user’s request reliably” use case — it is designed for “run this DAG every night at 2am.”
AWS Step Functions is the right choice when you are fully committed to AWS, need serverless execution (no workers to run), and your workflow logic fits into a state machine expressed in Amazon States Language JSON. The vendor lock-in and JSON-config-based workflow definition are significant trade-offs.
Temporal is the right choice when: your workflows have long durations or arbitrary waits, failure handling needs to be structural rather than bolted on, you want to write coordination logic in your primary language rather than a config DSL, and you are willing to operate the server (or pay for Temporal Cloud).
What Temporal Does Not Do
Temporal is not a scheduler. It does not replace cron for running jobs at regular intervals — though you can build cron-like behavior by having a workflow sleep and restart in a loop. workflow.Sleep(ctx, 24*time.Hour) is a valid pattern but is not the same as a cron job.
Temporal is not a stream processor. It is not a replacement for Kafka consumers or Flink. It handles individual workflow instances, not high-throughput event streams. If you are processing millions of events per second, Temporal is the wrong abstraction.
Temporal is not operationally free. The server has four services and a database dependency. Running it reliably in production requires attention to the History service’s shard distribution, database connection pooling, and careful schema migration on upgrades. Temporal Cloud (the managed offering) addresses this but costs money per action.
The honest characterization: Temporal solves a real and common problem — reliable multi-step distributed processes — better than anything else available. The complexity it introduces is real and front-loaded. Teams that operate it consistently report that it eliminates a category of bugs (partial failures, lost jobs, duplicate execution) that was previously a perpetual source of incidents. That trade is worth making for many systems. It is not worth making for a simple background job processor where Celery or a basic database-backed queue would suffice.
Comments