Runtimes & worlds: a full guide
How to build durable workflows across Next.js, Nitro, VPS / Docker, and Cloudflare — using the use workflow paradigm with Worlds, and how xFlow expresses the same shape with a signed multi-writer log instead of a single orchestrator.
Mental model
Every workflow engine has the same four layers.
The differences are what each layer is built from. Pin the four layers in your head and the rest of this guide is just substitution.
The four layers
Workflow definition, host, World, external runtime. WDK, Temporal, Cloudflare Workflows, and xFlow all implement these four — they just disagree about what each one is built from.
Workflow definition (TypeScript / DSL / DAG)
↓
Workflow host (framework + runtime adapter)
↓
World (durable backend: event log + queue + compute)
↓
External runtimes (Docker, GPU, edge worker, third-party API)Workflow definition
Code that describes the steps, transitions, and policies.
Workflow host
Framework + adapter that turns definitions into HTTP handlers and run drivers.
World
Durable backend — event log + queue + compute. Vercel, Postgres, Local, etc.
External runtime
Where the actual work happens — Docker, GPU, edge worker, third-party API.
Workflow SDK
The "use workflow" paradigm.
The Workflow Development Kit (WDK) by Vercel turns regular async TypeScript functions into durable code via two directives. The SWC compiler emits client / step / workflow handler bundles at build time.
Definition
Workflow code is a coordinator. Step code is the side-effectful boundary. Both are just async functions with a directive.
// server/workflows/process-video.ts
export async function processVideo(assetId: string) {
"use workflow"
const metadata = await fetchMetadata(assetId)
const transcript = await transcribeVideo(assetId)
const summary = await summarizeTranscript(transcript)
return { metadata, transcript, summary }
}
async function fetchMetadata(assetId: string) {
"use step"
return db.assets.find(assetId)
}
async function transcribeVideo(assetId: string) {
"use step"
return openai.audio.transcribe(assetId)
}
async function summarizeTranscript(transcript: string) {
"use step"
return openai.responses.create({ input: transcript })
}Trigger
Workflows start from app code via start(workflow, args). The compiler generates routes under /.well-known/workflow/v1/* for the durable runtime to drive the run.
// app/api/process/route.ts
import { start } from "workflow/api"
import { processVideo } from "@/server/workflows/process-video"
export async function POST(req: Request) {
const { assetId } = await req.json()
const run = await start(processVideo, [assetId])
return Response.json({ runId: run.id })
}What the compiler produces
Client mode
Workflow calls become HTTP requests to the workflow runtime.
Step mode
"use step" functions become individually addressable HTTP handlers.
Workflow mode
"use workflow" functions become orchestrators run inside a deterministic sandbox.
xFlow
The same four layers, expressed as a signed actor log.
xFlow keeps the workflow / step / coordinator distinction, but the run is one xSync actor whose log is a signed, content-addressed, multi-writer DAG. The workflow body is data, not a deterministic-replay function.
defineWorkflow + defineStep + link
Definitions are runtime values, not compiled artifacts. Placement and claim modes are first-class fields on every step.
// workflows/process-video.ts
import { defineWorkflow, defineStep, link } from "@decoperations/xflow-core"
export const processVideoWorkflow = defineWorkflow({
id: "media.process-video",
version: "1.0.0",
steps: {
fetchMetadata: defineStep({
id: "fetchMetadata",
type: "media.fetch-metadata",
sideEffects: { kind: "pure", idempotencyRequired: false },
}),
transcribe: defineStep({
id: "transcribe",
type: "media.transcribe",
placement: { required: ["docker"] },
claim: { mode: "lease", ttlMs: 15 * 60_000 },
sideEffects: { kind: "external-api", idempotencyRequired: true },
}),
summarize: defineStep({
id: "summarize",
type: "ai.summarize",
claim: { mode: "optimistic-idempotent" },
sideEffects: { kind: "cacheable", idempotencyRequired: true },
}),
},
links: [
link("fetchMetadata", "transcribe", { when: { type: "step.succeeded" } }),
link("transcribe", "summarize", { when: { type: "step.succeeded" } }),
],
})Any peer can register executors
A Next.js route, a Docker worker, and a browser tab can all call runtime.register for the same step type. The claim mode picks the winner.
// any peer — Next.js route, Docker worker, browser tab — can register executors
import { createXFlowRuntime } from "@decoperations/xflow-runtime"
import { detectLocalNode } from "@decoperations/xflow-provider-local"
import { createXSync } from "@decoperations/xsync-client"
import { flowRunView } from "@decoperations/xflow-core"
const client = await createXSync({ views: { flowRun: flowRunView } })
const runtime = createXFlowRuntime({ node: detectLocalNode({ id: "docker-worker-1" }) })
runtime.register("media.transcribe", async (ctx, input) => {
await ctx.progress({ phase: "downloading" })
return runFfmpegTranscribe(input)
})
const run = await runtime.start({ client, workflow: processVideoWorkflow, input: assetId })Side-by-side
Same problem space. Different load-bearing assumption about what a workflow run is.
| Concern | Workflow SDK | xFlow |
|---|---|---|
| Definition | "use workflow" / "use step" directives | defineWorkflow + defineStep + link() |
| Build step | SWC plugin transforms code at build time | None — definitions are runtime values |
| Run identity | Server-side orchestrator instance | One xSync actor (run_<id>) |
| State model | Deterministic replay of the orchestrator | Reducer over signed event log |
| Determinism trap | Yes — workflow body must be deterministic | No — body is free; reducer is deterministic |
| Multi-writer | No — single orchestrator | Yes — claim modes resolve the winner |
| Audit log | Internal event log; not signed | ed25519-signed events with actor authorship |
Worlds
The durability backend abstraction.
A World provides the workflow's event log, queue, and compute hooks. WDK ships seven; xFlow factors the same job across xSync stores and transports so each piece is swappable independently.
Workflow SDK Worlds
Pick a World based on where you deploy. Vercel for managed; Postgres for self-host; community Worlds (Redis, MongoDB, Turso, Jazz) for specific stacks.
| World | Best for | Notes | Self-host |
|---|---|---|---|
| @workflow/world-vercel | Vercel deployments — fully managed, zero config | Per-step queue lag (~4–5s) and currently iad1-only. Documented retention is plan-tier-bound. | — |
| @workflow/world-postgres | Self-hosted on VPS / Docker / Kubernetes / Fly / Railway / Render | graphile-worker queue + Postgres NOTIFY/LISTEN. Reference-grade per its own README. Not Vercel-compatible. | ✅ |
| @workflow/world-local | Local dev — writes to .workflow-data/ | Single-process. No durability across machines. Use only in development. | ✅ |
| @workflow/world-jazz (community) | Local-first / collaborative apps via Jazz Cloud | Hints at multi-device sync but community-maintained. | ✅ |
| @workflow/world-mongodb (community) | MongoDB-backed self-hosted durability | Community world — verify maturity before production use. | ✅ |
| @workflow/world-redis (community) | Redis / BullMQ-backed embedded queue | Good fit if Redis is already your operational primitive. | ✅ |
| @workflow/world-turso (community) | libSQL / SQLite-style durability at edge | Lightweight; pairs with edge / micro-VM hosts. | ✅ |
xFlow factors the World into stores + transports
xSync separates the three jobs a World does. Stores own persistence (@decoperations/xsync-store-s3worm, -indexeddb, -fs, -sqlite). Transports own message delivery (-ws, -sse, -http, -broadcast-channel). Compute is per-peer — every participant runs its own runtime against its own claim policies.
The result: you can mix and match, e.g. S3WORM for cold persistence + IndexedDB on the client + WS transport for live sync, without a managed-World vendor in the loop. Run event logs live under the xSync hierarchy because the run actor is an xSync actor.
Framework hosts
Where workflow code is discovered, compiled, and mounted.
Host integration map
WDK ships build-time integrations for nine host frameworks. xFlow's surface is smaller because it doesn't need a build-time transform — definitions are data, and any host that can run TypeScript and speak xSync transport can be a peer.
| Framework | Workflow SDK | xFlow |
|---|---|---|
| Next.js | First-class — App Router route generation | @decoperations/xflow-next |
| Nitro | modules: ["workflow/nitro"] | Treat the Nitro server as another peer; createXFlowRuntime + xSync client |
| Nuxt | Inherits Nitro module | Same — Nuxt server is a Nitro peer |
| SvelteKit | Vite-based integration | Browser + +server.ts peers, both write the run log |
| Astro | Vite-based integration | Astro endpoints become peers; same model |
| Vite | Lower-level Vite plugin | Browser-first usage already works on Vite-built apps |
| Hono / Express / Fastify | Wrapped by Nitro under the hood | Native — any HTTP server can host an xSync transport endpoint |
| Python (Beta) | Separate Python integration; smaller surface | Out of scope for v1 — TypeScript-only |
| Browser tab / Web Worker | Not supported as a workflow host | First-class — same code, IndexedDB / BroadcastChannel transports |
Pattern A
Vercel + Next.js.
The fastest path. UI, API routes, server actions, workflow starts, and managed durability all in one deployment.
Shape
Best for: AI agents, RAG ingestion, email sequences, billing workflows, media upload pipelines, human approvals — anything where the orchestrator and the UI live in the same Next.js app.
Next.js on Vercel
├─ app/api/start/route.ts
├─ workflows/*.ts "use workflow" / "use step"
├─ Vercel World (managed)
└─ steps fetch external services (Docker, GPU, Stripe, etc.)xFlow on Vercel + Next.js
Use @decoperations/xflow-next's createXFlowRouteHandlers() to mount xSync's heads / events / SSE endpoints into App Router. The Next.js process is one peer; a browser tab loading the app is another peer. They share the same run log via the SSE transport.
The Vercel function tier limit (300s default, 800s max on Pro / Enterprise with Fluid Compute) still applies to any individual step, but the run itself can outlive any single function invocation because state lives on the log, not in the orchestrator's memory.
Pattern B
Nitro as a framework-neutral workflow control plane.
Use Nitro when you want a workflow service that's separate from the web app — Express / Hono / Fastify / Nuxt-style backends without forcing them all into Next.js.
Install & configure
The package is workflow. The Nitro module automatically adds the workflow transform plugin, builds workflow bundles, and generates handlers under /.well-known/workflow/v1/*.
pnpm add workflow
# nitro.config.ts
import { defineConfig } from "nitro"
export default defineConfig({
preset: "vercel",
serverDir: "./server",
modules: ["workflow/nitro"],
vercel: {
functionRules: {
"/.well-known/workflow/v1/**": {
maxDuration: 800,
memory: 4096,
},
},
},
})When to pick this
Multi-app monorepos with Next.js + Nuxt + others sharing one workflow service.
API-first products that don't want UI and durable jobs in the same deployment.
Any case where you want the same workflow service deployable to Vercel, Fly, Railway, Render, or a plain VPS without rewriting.
xFlow as a Nitro peer
Spin up createXFlowRuntime inside your Nitro server with the appropriate xSync client and store. The Nitro app becomes one peer. Your Next.js frontend can be another peer (browser-side or server-side). They both write to the same run actor log.
No /.well-known/workflow/v1/* contract is required — xSync's heads / events / SSE handlers are the wire protocol, and they're already framework-agnostic.
Pattern C
Self-hosted on VPS / Docker / Kubernetes.
When you want provider portability, heavy native compute, or to avoid managed-vendor lock-in for the durability layer.
Postgres World
Postgres-backed durability. Uses graphile-worker for retries and Postgres NOTIFY / LISTEN for real-time events. Good fit for Docker, Kubernetes, Fly, Railway, Render. Not Vercel-compatible (Vercel deployments use Vercel World).
// server/world.ts
import { createPostgresWorld } from "@workflow/world-postgres"
export const world = createPostgresWorld({
connectionString: process.env.DATABASE_URL!,
// graphile-worker queue + Postgres NOTIFY/LISTEN under the hood
})
await world.start()Heavy-compute worker pattern
Don't make a Docker box your durable orchestrator first. Make it a compute worker that the workflow host calls.
async function submitRenderJob(input: RenderInput) {
"use step"
const res = await fetch(`${process.env.DOCKER_WORKER_URL}/jobs`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.WORKER_TOKEN}` },
body: JSON.stringify(input),
})
return res.json()
}xFlow on Docker / VPS
Stand up an S3WORM-backed xSync store on any S3-compatible bucket (R2, B2, Storj, actual S3) plus a small WS transport endpoint. Your Docker worker runs createXFlowRuntime and registers executors for steps with placement: { required: ["docker"] }.
No graphile-worker, no NOTIFY / LISTEN, no Postgres dependency unless you specifically want one (in which case use @decoperations/xsync-store-sqlite or a Postgres adapter). The durability story is "the log is in S3WORM," and S3WORM replicates as a normal bucket.
Pattern D
Cloudflare-native.
Cloudflare ships its own durable workflow primitives, distinct from Workflow SDK. Workflows for orchestration, Durable Objects for coordination, Dynamic Workflows for tenant-provided code.
Cloudflare Workflows
Class-based; run(event, step) takes a step object with step.do, step.sleep, and step.waitForEvent. Up to 25,000 steps per workflow on paid plans, sleeps up to 365 days, no fixed total-run cap.
import { WorkflowEntrypoint } from "cloudflare:workers"
export class ImageProcessingWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const image = await step.do("fetch", async () => {
return this.env.BUCKET.get(event.params.imageKey)
})
const description = await step.do("describe", async () => {
return this.env.AI.run("@cf/llava-hf/llava-1.5-7b-hf", { image })
})
await step.waitForEvent("await approval", {
type: "approval",
timeout: "24 hours",
})
return description
}
}Durable Objects
Stateful coordination primitives — single global instance per name with attached SQLite. Use for per-user / session state, collaborative editing, locks, WebSocket fanout. Not the same product as Cloudflare Workflows.
Dynamic Workflows
Worker Loader + WorkflowEntrypoint that routes durable execution to tenant-provided code. Use for agent platforms, multi-tenant builders, and CI/CD where the workflow logic isn't part of your deployment.
xFlow on Cloudflare
A Cloudflare provider adapter is on the xFlow Phase 2 roadmap. Today, you can run xFlow inside a Worker via the Node compat layer; what's not yet first-class is using a Durable Object as the authoritative single-writer for authority-claim steps.
Tenant-provided workflow code is interesting because xFlow definitions are already data. Persisting a tenant's WorkflowDefinition JSON and loading it at run time doesn't need a separate product layer — the runtime treats user-provided definitions the same as built-in ones.
Pattern E
Hybrid: Vercel + Cloudflare + Docker.
The realistic shape for most non-trivial systems — UI on Vercel, edge ingress on Cloudflare, heavy compute on Docker, durable state somewhere central.
The honest WDK shape
In Workflow SDK terms, "hybrid" really means three separate products glued together. The workflow run is a single deployment's responsibility; everything else is the workflow calling out via fetch and consuming results.
Cloudflare (edge ingress, R2, Durable Objects)
→ Next.js on Vercel (UI + workflow host + Vercel World)
→ step calls Docker worker (BitLaunch / Fly / Railway)
→ step calls Cloudflare Workers AI / R2 / D1
→ step calls third-party APIs
Run state lives on Vercel.
Other systems are dumb workers from the workflow's perspective.xFlow's structural advantage shows up here
Because the run is one xSync actor, the same run can be jointly advanced by a Vercel function, a Docker worker, a Cloudflare Worker, and a browser tab — each writing signed events into the same log under different claim policies.
authority claims pin irreversible side effects to a specific server. deterministic-election resolves duplicate-tolerant compute across peers. lease covers long-running compute on a Docker box. The hybrid topology stops being three products glued together and becomes one run with multiple participants.
Calling external runtimes
Steps as durable boundaries to whatever runs the work.
Three patterns
Submit + poll, submit + webhook, queue-based. All three have the step as the durable boundary; the external runtime is just an HTTP / queue target. Idempotency keys prevent duplicate execution on retry.
Submit + poll. Step submits a job, gets a jobId, then polls GET /jobs/:id until done. Simple but burns step time on long jobs.
Submit + webhook. Step submits, returns control, the workflow waits on a hook / signal. Worker calls back when done. Better for multi-minute / hour jobs.
Queue-based. Step pushes into SQS / RabbitMQ / NATS, the worker pool consumes. Fan-out friendly; idempotency keys critical.
External runtimes can also be peers
In the WDK model, an external runtime is always opaque to the workflow — it's an HTTP target. In xFlow, an external runtime can also be a peer with its own xSync client. A Docker worker can claim and run a step directly, emitting step.progress and step.succeeded on the same log instead of going through a webhook callback.
The "fetch + webhook" pattern still works fine in xFlow when the external runtime isn't worth integrating as a peer (third-party APIs, vendor services). For your own workers, full peer participation gives you signed audit and live progress for free.
Step placement
Where the WDK model and the xFlow model meaningfully diverge.
In WDK, a step handler runs in the workflow host's deployment. Multi-runtime is achieved by having the step fetch out to an external service. In xFlow, placement is a first-class field on every step definition.
Placement as a contract
Every peer evaluates placement.required / forbidden against its own capabilities before attempting to claim. Wrong-host peers never run the step.
defineStep({
id: "transcode",
type: "media.transcode",
// First-class — the runtime checks this against the local peer's capabilities
// before claiming the step. Wrong-host peers never run it.
placement: {
required: ["docker", "ffmpeg-binary"],
forbidden: ["browser-tab", "edge-worker"],
},
claim: { mode: "lease", ttlMs: 30 * 60_000 },
sideEffects: { kind: "compute-heavy", idempotencyRequired: true },
})System comparison
How seven systems answer the multi-runtime question.
Comparison matrix
Authoring surface, runtime model, where steps run, multi-writer support, and browser participation.
| System | Authoring | Runtime | Per-step placement | Multi-writer | Browser |
|---|---|---|---|---|---|
| Workflow SDK / WDK | "use workflow" / "use step" directives | SWC-compiled handler files; sandboxed orchestrator | Implicit — same deployment as host; external work via fetch from steps | No — single orchestrator instance per run | No — server-only host |
| Vercel Workflow | Same as WDK — directives | Vercel Functions / Fluid Compute managed | Implicit; Vercel-managed regions | No | No |
| Temporal | TS class with @workflow / @activity decorators | Long-running worker processes that poll task queues | Per-task-queue routing — activities can target different worker pools | No — sticky workers; single workflow shard | No |
| Inngest | createFunction() + step.run / step.sleep / step.waitForEvent | Inngest invokes your HTTP endpoint per step | All steps run in your endpoint's runtime; external work via fetch | No | No |
| Trigger.dev v3 | task() / runs.trigger() | CRIU-checkpointed long-running JS process | Single task runtime; external work via fetch | No | No |
| Cloudflare Workflows | class extends WorkflowEntrypoint with run(event, step) | Workers + Durable-Object-backed durability layer | All steps run in the Worker; external work via fetch / bindings | No | No |
| Cloudflare Durable Objects | Class with stub APIs; not strictly a workflow primitive | Single-activation actor with attached SQLite | Coordination point; one DO per name | No — single-writer per object is the model | Indirect (Workers can be edge-bound) |
| AWS Step Functions | ASL JSON / CDK builders | AWS-managed state machine engine | Tasks routed to Lambda / ECS / Activity workers / etc. | No | No |
| xFlow.WTF | defineWorkflow + defineStep + link() | Per-peer runtime; any peer (Node, Docker, browser, worker) can host executors | First-class — placement: { required, forbidden } enforced by every peer before claiming | Yes — claim modes (authority / election / optimistic / lease) resolve which peer wins each step | Yes — browser tabs and Web Workers are first-class peers |
Case studies
Five concrete shapes — WDK first, then xFlow.
Video rendering pipeline
User uploads a video, system transcodes, captions, generates social variants, and notifies when done.
WDK shape
Next.js on Vercel hosts the workflow. Steps call a Docker worker on a VPS via HTTP, then poll or wait on a webhook callback. Output artifacts go to R2 / S3 / Blob.
xFlow shape
The Docker worker is a peer that registers `media.transcode` directly. It claims the step under a `lease` mode, runs ffmpeg, and emits `step.succeeded` to the run log. Next.js subscribes to the same log and reactively updates the UI without polling.
RAG ingestion pipeline
Ingest PDFs, chunk, embed, index, and expose search.
WDK shape
Workflow on Vercel orchestrates extract → chunk → embed → index. Embeddings step calls an external GPU API. Vector index lives in pgvector / Pinecone / Qdrant.
xFlow shape
Same flow, but a GPU box and a Web Worker can both register `embed-batch` under `optimistic-idempotent`. Whoever finishes first wins. The UI shows progress in real time because every `step.progress` event is replicated to subscribers.
Human-in-the-loop agent
Agent drafts an action, waits for approval, then executes.
WDK shape
Workflow uses `step.waitForEvent` (Cloudflare) or a webhook hook (WDK) to pause for approval. Approval re-enters the orchestrator.
xFlow shape
A `link` with `when: { type: "external-signal", signal: "approved" }` advances the run. The approval is a signed `xflow.approval.submitted` event from the user's browser tab — same audit log as everything else, no separate webhook channel.
Multi-tenant workflow builder
Customers design or upload their own workflow logic.
WDK shape
WDK assumes workflow code is part of your deployment. For tenant-provided code, Cloudflare Dynamic Workflows (Worker Loader + WorkflowEntrypoint) is currently the cleanest option.
xFlow shape
Workflow definitions are runtime values, not compiled artifacts. Tenants store their `WorkflowDefinition` JSON; the runtime loads and runs it on whichever peer has the right placement capabilities. No second product needed.
CI / CD pipeline
Each branch has a pipeline; some steps need isolates, others need full Docker, others need approvals.
WDK shape
Cloudflare proposes Dynamic Workflows for orchestration + Dynamic Workers for lightweight steps + Sandboxes for heavy Docker / Postgres / Rust steps. Vercel equivalent: WDK + BitLaunch / Fly / Railway worker pool.
xFlow shape
One run. Lightweight steps with `placement: { required: ["edge"] }` claim on Cloudflare. Heavy steps with `placement: { required: ["docker"] }` claim on a build VM. Approvals come in as signed external signals. The pipeline log is the audit log.
The support gap
What WDK ships, what you still build, and what xFlow inverts.
First-class in WDK
Durable orchestration with deterministic replay.
Per-step automatic retry / resume.
Build-time framework integrations for nine hosts.
Managed Vercel World with built-in observability.
Self-host via Postgres / Redis / Mongo / Turso.
Still your job
Per-step placement across providers.
Docker / GPU worker lifecycle.
Signed job protocols and webhook security.
Artifact storage conventions.
Cross-runtime tracing.
Workflow version migration across hosts.
What xFlow inverts
Signed audit log out of the box.
Browser / Web Worker as first-class peers.
Multi-writer collaboration on a single run.
S3-portable storage layer.
Placement as a step field, not an architecture decision.
Decision table
If you want X, pick Y.
| Need | Pick |
|---|---|
| Fastest path with a Next.js app on Vercel | WDK + Vercel World |
| Framework-neutral durable workflow service | WDK + Nitro on Vercel, or Postgres World on a VPS |
| Self-hosted on a single Postgres | WDK + @workflow/world-postgres on Docker / VPS |
| Heavy native compute (ffmpeg, GPU, Playwright, Rust) | Docker worker called from a step (any system) |
| Edge-native orchestration for global low latency | Cloudflare Workflows + Durable Objects + R2 / D1 |
| Per-user / per-session coordination | Cloudflare Durable Objects |
| Enterprise-grade orchestration with versioning APIs | Temporal |
| Event-driven jobs with managed infra | Inngest |
| Tenant-provided workflow code | Cloudflare Dynamic Workflows — or xFlow definitions as data |
| Browser participation, offline durability, signed audit | xFlow.WTF |
| Multi-writer collaboration on a single run | xFlow.WTF |
| Same workflow run spanning Vercel + Docker + Cloudflare peers | xFlow.WTF — peers all write to the same actor log |
Sources
Citations for the claims in this guide. Verified May 2026.