How TypeScript Turns Ordinary Code into Durable Workflows

Peter Bubenik · Vercel · · Source
Image for The best workflow engine is a programming language

After studying this material, you should be able to:

  1. Explain what durable execution is and why traditional workflow engines are complex
  2. Understand how Workflow SDK simplifies orchestration using native TypeScript patterns
  3. Identify the core primitives: "use workflow", "use step", hooks, and webhooks
  4. Compare Workflow SDK's architecture against traditional engines like Temporal
  5. Apply the mental model of "code as a DAG" to understand how workflows are structured

Step-by-Step Teaching

Step 1: The Core Problem — What Is Durable Execution?

The Fundamental Challenge

Imagine you are running a long process:

User places order → Charge payment → Send email → Update inventory

Normal code runs on stateless, unreliable infrastructure. This means:

  • Your server can crash mid-process
  • Network calls can fail
  • A step can succeed but the next one never starts

Durable execution solves this by ensuring that even if infrastructure fails, your process resumes exactly where it left off.

Real World Analogy

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.


Step 2: How Workflows Were Built Before — The Old Way

Explicit DAGs (Directed Acyclic Graphs)

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:

  • Define each node explicitly
  • Define each dependency explicitly
  • Bury your actual business logic inside graph nodes

Why This Felt Backwards

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.


Step 3: What Temporal Got Right (And What Was Still Hard)

The Breakthrough Idea

Temporal proved you could write normal sequential code and have an engine make it durable underneath. That was revolutionary.

But Temporal Had Real Costs

ProblemWhat It Meant
Complex infrastructureFrontend, History, Matching, Worker services + database
You own the worker fleetYour own Kubernetes cluster
Versioning in-flight runsChanging code breaks running workflows
Three separate primitivesSignals, Queries, Updates — each with different rules

The Versioning Problem Explained

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.


Step 4: The Core Concept — Code Is Already a DAG

The Mental Model Shift

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.


Step 5: Workflow SDK Primitives

Primitive 1: "use workflow" — The Orchestrator

This 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:

  • Calls steps using await
  • Uses normal control flow (if, for, Promise.all)
  • Does NOT do I/O directly — delegates to steps

Primitive 2: "use step" — The Unit of Work

This 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:

  • ✅ Automatic retry on uncaught errors
  • ✅ Result persisted to storage
  • ✅ Never re-executed after success

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;

Primitive 3: Hooks — The Human-in-the-Loop

Traditional engines had three separate concepts:

Temporal ConceptPurposeProblem
SignalSend data into a running workflowFire-and-forget, buffered
QueryRead state from a workflowCannot block
UpdateSend data and get a responseComplex 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"


Primitive 4: Webhooks — Hooks With a URL

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

Step 6: Architecture — A Library, Not a Platform

The Traditional Model (Bring Infrastructure to the Framework)

Your Code
    ↓
Workflow Engine (Temporal Server)
    ↓
Your Worker Fleet (Kubernetes)
    ↓
Your Database (Cassandra/Postgres)

You must operate all of this.

The Workflow SDK Model (Bring the Framework to Your Infrastructure)

Your Code + Workflow SDK (library)
    ↓
Your existing infrastructure
(database + queue you already run)

The "World" Abstraction

The SDK talks to a single interface called the World, which covers:

  • Storage (durability)
  • Queuing
  • Auth
  • Streaming

You can swap implementations:

LayerOptions
DurabilityPostgres, Cassandra, filesystem, Turso, Durable Objects
QueueVercel Queues, SQS, Cloudflare Queues, Kafka
StreamsRedis, Kafka, etc.

Key principle: Swapping any layer never touches your workflow code.


Step 7: The Versioning Solution

The Problem Restated

Run starts on v1 of code
    → Step 1 completes
    → You deploy v2
    → Step 2 tries to run on v2
    → Replay of history breaks ❌

Workflow SDK's Solution

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.


Step 8: The Performance Goal

Why Performance Matters for Abstraction Quality

Each step currently requires:

  1. A network call
  2. A queue round-trip
  3. A durable write to storage

This overhead means developers might ration steps — avoiding checkpoints because they feel expensive. That breaks the abstraction.

The Goal: Steps Should Feel Free

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.


Summary: The Complete Mental Model

┌─────────────────────────────────────────────────────┐
│                  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)          │
└─────────────────────────────────────────────────────┘

Core Takeaways

ConceptOne-Line Summary
Durable executionResume exactly where you left off after any failure
Code as DAGYour 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
HookOne primitive replacing signals, queries, and updates
WebhookA hook with an auto-generated HTTP URL
WorldSwappable infrastructure interface
VersioningPin runs to their starting deployment

More to study