Guide

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.

four layers
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
// 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
// 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
// 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
// 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.

ConcernWorkflow SDKxFlow
Definition"use workflow" / "use step" directivesdefineWorkflow + defineStep + link()
Build stepSWC plugin transforms code at build timeNone — definitions are runtime values
Run identityServer-side orchestrator instanceOne xSync actor (run_<id>)
State modelDeterministic replay of the orchestratorReducer over signed event log
Determinism trapYes — workflow body must be deterministicNo — body is free; reducer is deterministic
Multi-writerNo — single orchestratorYes — claim modes resolve the winner
Audit logInternal event log; not signeded25519-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.

WorldBest forNotesSelf-host
@workflow/world-vercelVercel deployments — fully managed, zero configPer-step queue lag (~4–5s) and currently iad1-only. Documented retention is plan-tier-bound.
@workflow/world-postgresSelf-hosted on VPS / Docker / Kubernetes / Fly / Railway / Rendergraphile-worker queue + Postgres NOTIFY/LISTEN. Reference-grade per its own README. Not Vercel-compatible.
@workflow/world-localLocal 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 CloudHints at multi-device sync but community-maintained.
@workflow/world-mongodb (community)MongoDB-backed self-hosted durabilityCommunity world — verify maturity before production use.
@workflow/world-redis (community)Redis / BullMQ-backed embedded queueGood fit if Redis is already your operational primitive.
@workflow/world-turso (community)libSQL / SQLite-style durability at edgeLightweight; pairs with edge / micro-VM hosts.
xFlow

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.

FrameworkWorkflow SDKxFlow
Next.jsFirst-class — App Router route generation@decoperations/xflow-next
Nitromodules: ["workflow/nitro"]Treat the Nitro server as another peer; createXFlowRuntime + xSync client
NuxtInherits Nitro moduleSame — Nuxt server is a Nitro peer
SvelteKitVite-based integrationBrowser + +server.ts peers, both write the run log
AstroVite-based integrationAstro endpoints become peers; same model
ViteLower-level Vite pluginBrowser-first usage already works on Vite-built apps
Hono / Express / FastifyWrapped by Nitro under the hoodNative — any HTTP server can host an xSync transport endpoint
Python (Beta)Separate Python integration; smaller surfaceOut of scope for v1 — TypeScript-only
Browser tab / Web WorkerNot supported as a workflow hostFirst-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.

topology
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

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/*.

nitro.config.ts
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

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
// 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.

step calling a Docker worker
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()
}
The step handler runs in the workflow runtime; the actual work happens on Docker / a GPU box / Modal / RunPod / AWS Batch / etc. The step is the durable boundary; the external runtime is just a fetch target.
xFlow

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.

cloudflare workflow
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

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.

topology
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

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.

xFlow

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 with placement
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 },
})
The same workflow definition can describe steps that run on a Docker box (transcoding ffmpeg), a Vercel function (calling Stripe), and a browser tab (rendering a preview). Each peer's runtime checks placement, and only eligible peers compete for the claim.

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.

SystemAuthoringRuntimePer-step placementMulti-writerBrowser
Workflow SDK / WDK"use workflow" / "use step" directivesSWC-compiled handler files; sandboxed orchestratorImplicit — same deployment as host; external work via fetch from stepsNo — single orchestrator instance per runNo — server-only host
Vercel WorkflowSame as WDK — directivesVercel Functions / Fluid Compute managedImplicit; Vercel-managed regionsNoNo
TemporalTS class with @workflow / @activity decoratorsLong-running worker processes that poll task queuesPer-task-queue routing — activities can target different worker poolsNo — sticky workers; single workflow shardNo
InngestcreateFunction() + step.run / step.sleep / step.waitForEventInngest invokes your HTTP endpoint per stepAll steps run in your endpoint's runtime; external work via fetchNoNo
Trigger.dev v3task() / runs.trigger()CRIU-checkpointed long-running JS processSingle task runtime; external work via fetchNoNo
Cloudflare Workflowsclass extends WorkflowEntrypoint with run(event, step)Workers + Durable-Object-backed durability layerAll steps run in the Worker; external work via fetch / bindingsNoNo
Cloudflare Durable ObjectsClass with stub APIs; not strictly a workflow primitiveSingle-activation actor with attached SQLiteCoordination point; one DO per nameNo — single-writer per object is the modelIndirect (Workers can be edge-bound)
AWS Step FunctionsASL JSON / CDK buildersAWS-managed state machine engineTasks routed to Lambda / ECS / Activity workers / etc.NoNo
xFlow.WTFdefineWorkflow + defineStep + link()Per-peer runtime; any peer (Node, Docker, browser, worker) can host executorsFirst-class — placement: { required, forbidden } enforced by every peer before claimingYes — claim modes (authority / election / optimistic / lease) resolve which peer wins each stepYes — 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.

NeedPick
Fastest path with a Next.js app on VercelWDK + Vercel World
Framework-neutral durable workflow serviceWDK + Nitro on Vercel, or Postgres World on a VPS
Self-hosted on a single PostgresWDK + @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 latencyCloudflare Workflows + Durable Objects + R2 / D1
Per-user / per-session coordinationCloudflare Durable Objects
Enterprise-grade orchestration with versioning APIsTemporal
Event-driven jobs with managed infraInngest
Tenant-provided workflow codeCloudflare Dynamic Workflows — or xFlow definitions as data
Browser participation, offline durability, signed auditxFlow.WTF
Multi-writer collaboration on a single runxFlow.WTF
Same workflow run spanning Vercel + Docker + Cloudflare peersxFlow.WTF — peers all write to the same actor log