After studying this material, you should be able to:
"use workflow", "use step", hooks, and webhooksImagine you are running a long process:
User places order → Charge payment → Send email → Update inventory
Normal code runs on stateless, unreliable infrastructure. This means:
Durable execution solves this by ensuring that even if infrastructure fails, your process resumes exactly where it left off.
Think of it like a save point in a video game. If your game crashes, you reload from the last checkpoint — not from the very beginning.
Traditional tools like Apache Airflow required you to manually draw your workflow as a graph:
Task A ──→ Task B ──→ Task C
↓
Task D
You had to:
A programming language already expresses this naturally:
// This IS a DAG — you just can't see it drawn as boxes
const order = await fetchOrder(id); // Node A
await chargePayment(order); // Node B (depends on A)
await Promise.all([ // Nodes C and D in parallel
sendEmail(order),
updateInventory(order)
]);
Key Insight: An Abstract Syntax Tree (AST) — the internal representation of your code — is already a DAG. Your code already describes the graph.
Temporal proved you could write normal sequential code and have an engine make it durable underneath. That was revolutionary.
| Problem | What It Meant |
|---|---|
| Complex infrastructure | Frontend, History, Matching, Worker services + database |
| You own the worker fleet | Your own Kubernetes cluster |
| Versioning in-flight runs | Changing code breaks running workflows |
| Three separate primitives | Signals, Queries, Updates — each with different rules |
Workflow starts → runs step 1 → YOU DEPLOY NEW CODE → step 2 runs...
The engine replays history to reconstruct state. If your new code has different logic, the replay breaks with a non-determinism error.
The fix was a patching API that turned workflow code into this over time:
if (patched('change-001')) {
// new logic
} else if (patched('change-002')) {
// newer logic
} else {
// original logic
}
This is called code rot — your workflow becomes a thicket of version flags.
Stop thinking:
"I need to define a graph, then put logic in the nodes"
Start thinking:
"I write normal code. The framework reads my control flow as the graph."
// Sequential = one path through the DAG
const a = await stepA();
const b = await stepB(a);
// Parallel = branching paths that merge
const [x, y] = await Promise.all([stepX(), stepY()]);
// Conditional = branching paths
if (condition) {
await stepC();
} else {
await stepD();
}
Every programming construct maps directly to a graph structure. The language is the workflow definition language.
"use workflow" — The OrchestratorThis marks a function as the coordinator. It decides what runs and in what order. It must be deterministic — no direct side effects.
export async function processOrderWorkflow(orderId: string) {
"use workflow"; // ← marks this as the orchestrator
const order = await fetchOrder(orderId); // calls a step
await chargePayment(order); // calls another step
return { orderId, status: "completed" };
}
Rules for the orchestrator:
awaitif, for, Promise.all)"use step" — The Unit of WorkThis marks a function as a durable checkpoint. When a step completes, its result is saved. If the system crashes, the step does not re-run.
async function chargePayment(order: Order) {
"use step"; // ← marks this as a durable unit
// Full Node.js access here — this is where side effects live
const charge = await stripe.charges.create({ /* ... */ });
return { chargeId: charge.id };
}
What steps give you automatically:
Controlling retry behavior:
// Stop retrying immediately
throw new FatalError("Card permanently declined");
// Retry after a delay
throw new RetryableError("Rate limited", { retryAfter: 60_000 });
// Limit total retries
chargePayment.maxRetries = 3;
Traditional engines had three separate concepts:
| Temporal Concept | Purpose | Problem |
|---|---|---|
| Signal | Send data into a running workflow | Fire-and-forget, buffered |
| Query | Read state from a workflow | Cannot block |
| Update | Send data and get a response | Complex rules |
Workflow SDK replaces all three with one concept: the hook
// Create a hook — a channel that can receive data
const hook = createHook<{ approved: boolean }>();
// Workflow parks here until someone sends data to this hook
const { approved } = await hook;
A hook is simply: "pause here and wait for external input"
A webhook is a hook that automatically gets a real HTTP endpoint:
export async function approveExpense(expense: Expense) {
"use workflow";
// Creates a real, callable URL — no routing config needed
const webhook = createWebhook();
// Durable step: send the URL to a manager
await emailManager(expense.managerEmail, webhook.url);
// Parks here — could be seconds or weeks
const request = await webhook;
const { approved } = await request.json();
return { expenseId: expense.id, approved };
}
What makes this powerful:
Normal approach: Workflow SDK approach:
───────────────── ──────────────────────
1. Create webhook route 1. const webhook = createWebhook()
2. Store workflow ID in DB 2. await webhook
3. Handle auth on the route (done)
4. Look up workflow on callback
5. Send signal to resume it
Your Code
↓
Workflow Engine (Temporal Server)
↓
Your Worker Fleet (Kubernetes)
↓
Your Database (Cassandra/Postgres)
You must operate all of this.
Your Code + Workflow SDK (library)
↓
Your existing infrastructure
(database + queue you already run)
The SDK talks to a single interface called the World, which covers:
You can swap implementations:
| Layer | Options |
|---|---|
| Durability | Postgres, Cassandra, filesystem, Turso, Durable Objects |
| Queue | Vercel Queues, SQS, Cloudflare Queues, Kafka |
| Streams | Redis, Kafka, etc. |
Key principle: Swapping any layer never touches your workflow code.
Run starts on v1 of code
→ Step 1 completes
→ You deploy v2
→ Step 2 tries to run on v2
→ Replay of history breaks ❌
Each run is pinned to the deployment that started it:
Run starts on deployment #42
→ Step 1 completes
→ You deploy v2 (deployment #43)
→ Step 2 still runs on deployment #42 ✅
→ New runs start on deployment #43
This works because Vercel keeps immutable deployments available for long periods.
Design principle: Move complexity upstream. If every developer hits the same hard problem and solves it the same painful way, the framework has failed them. A good framework moves that complexity to the infrastructure layer.
Each step currently requires:
This overhead means developers might ration steps — avoiding checkpoints because they feel expensive. That breaks the abstraction.
The ideal:
// Before: a plain function
async function validateAddress(addr: Address) {
return someValidation(addr);
}
// After: a durable checkpoint with observability, retries, persistence
async function validateAddress(addr: Address) {
"use step";
return someValidation(addr);
}
Two words. No other changes. No performance penalty worth thinking about.
┌─────────────────────────────────────────────────────┐
│ YOUR WORKFLOW FILE │
│ │
│ "use workflow" ← orchestrator (pure coordination) │
│ ↓ │
│ await stepA() ← "use step" (durable checkpoint) │
│ ↓ │
│ await stepB() ← "use step" (durable checkpoint) │
│ ↓ │
│ const hook = createWebhook() │
│ await hook ← parks until external input │
│ ↓ │
│ return result │
└─────────────────────────────────────────────────────┘
↓ compiled by framework ↓
┌─────────────────────────────────────────────────────┐
│ YOUR EXISTING INFRASTRUCTURE │
│ (database + queue you already have) │
└─────────────────────────────────────────────────────┘
| Concept | One-Line Summary |
|---|---|
| Durable execution | Resume exactly where you left off after any failure |
| Code as DAG | Your control flow already is the workflow graph |
"use workflow" | Marks the coordinator — no direct side effects |
"use step" | Marks a checkpoint — retried, persisted, never re-run |
| Hook | One primitive replacing signals, queries, and updates |
| Webhook | A hook with an auto-generated HTTP URL |
| World | Swappable infrastructure interface |
| Versioning | Pin runs to their starting deployment |