For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Durable workflows
Add Cloudflare Workflows to Edge Kit for multi-step processes that retry each step and wait for days, with a tested account export that writes to R2.
Some product processes take longer than a single request. Generating a customer's data export, provisioning an account across several services, or following up a few days after signup all involve several steps, other services, and time in between. If one part fails, you want to retry that part, not start over.
Cloudflare Workflows runs these processes as durable steps. Cloudflare saves the result of each completed step, retries only the step that failed, and lets an instance sleep for days without keeping a Worker running.
In this recipe, you add a Workflow to Edge Kit and use it to build an account data export. A customer requests an export from settings, the Workflow writes the file to R2, the settings card offers the download, and the file is deleted a week later.
The Workflow files below are new example files, not part of the kit. Finish local development setup first.
Why Cloudflare Workflows?
Workflows run inside your existing Worker deployment, with the same D1, R2, email, and AI bindings as the rest of your app. Cloudflare stores each instance's progress, so you can build multi-step processes without operating a separate orchestration service or job server. An instance that is sleeping or waiting for an event uses no CPU time.
Workflows and background jobs
Edge Kit already includes background jobs on Cloudflare Queues. Both run work outside the customer's request, but they suit different kinds of work:
| Work | Use |
|---|---|
| One self-contained task, such as a notification email | Background job |
| Many independent messages processed in batches | Background job |
| Several dependent steps that call different services | Workflow |
| Waiting days between steps, or until something happens | Workflow |
| A process you want to inspect and restart step by step | Workflow |
The two also work together: a queue handler can start a Workflow, and a Workflow step can send a queue message.
If you also build on Core, its background tasks guides cover the same kind of work on its Next.js stack with Vercel Workflows, Inngest, and Trigger.dev.
Instances and steps
A Workflow is a class in your Worker that describes the process. Each run is an instance with its own ID and parameters, such as one customer's export.
Inside the class, step.do wraps one unit of work. Cloudflare saves the value a step returns and retries the step when it throws. step.sleep pauses the instance for a set time, and step.waitForEvent pauses it until your app sends an event.
When an instance resumes, its run method starts again from the top. Completed steps return their saved results immediately instead of running again, so the instance quickly reaches the point where it stopped. In this sketch, writeFile and deleteFile stand for your own operations:
const key = await step.do("write file", () => writeFile());
await step.sleep("keep file", "7 days");
await step.do("delete file", () => deleteFile(key));A week later, run starts again. The write file step returns the key it saved instead of writing a second file, and the instance continues with delete file. This replay model is the reason for a few rules the example follows:
- Keep state in step results. A variable set outside a step loses its value when the instance resumes.
- Keep step names stable. The name identifies a step's saved result.
- Make each step safe to repeat. A step can run again after a failure, so write to a fixed object key or set a status rather than appending.
- Return references, not private data. Step results are stored with the instance and appear in the dashboard. Return an object key, not the file contents.
Cloudflare's rules of Workflows explain each rule in more detail.
Account export
The example connects the Workflow to the kit's auth, database, storage, and settings UI:
- Request: the customer selects Request export in account settings. A protected server function creates a pending export record in D1 and starts an instance with that record's ID.
- File: a step loads the account data from D1 and writes a JSON file to R2. It returns only the object key.
- Ready: a second step marks the record ready. The settings card shows a download link served by the kit's protected storage route.
- Expiry: the instance sleeps for seven days, then deletes the file and marks the record expired.
- Failure: if the file step runs out of retries, the Workflow marks the record failed, and the customer can request a new export.
The export record is the product's view of the work, which the UI reads from D1. The Workflow instance is the execution history you inspect when something goes wrong.
Create the export table
Follow schema changes to add a table that tracks each export's owner, status, and file:
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { user } from "./auth";
export const accountExport = sqliteTable(
"account_export",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["pending", "ready", "failed", "expired"],
})
.notNull()
.default("pending"),
objectKey: text("object_key"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [index("account_export_userId_idx").on(table.userId)],
);Export it from the schema barrel:
export * from "./auth";
export * from "./account-export";Generate the migration, review its SQL, and apply it locally:
pnpm exec drizzle-kit generate
pnpm db:migrate --localThe cascade removes a customer's export records when their account is deleted.
Add the export services
These helpers create and update export records and write the file. They read D1 and R2, so they belong in a .server.ts file:
import { env } from "cloudflare:workers";
import { desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { account, accountExport, user } from "@/db/schema";
type ExportStatus = (typeof accountExport.$inferSelect)["status"];
export async function createExport(userId: string) {
const id = crypto.randomUUID();
await db.insert(accountExport).values({ id, userId });
return id;
}
export function getExport(id: string) {
return db.query.accountExport.findFirst({
where: eq(accountExport.id, id),
});
}
export function getLatestExport(userId: string) {
return db.query.accountExport.findFirst({
where: eq(accountExport.userId, userId),
orderBy: desc(accountExport.createdAt),
columns: { id: true, status: true, objectKey: true, createdAt: true },
});
}
export async function setExportStatus(
id: string,
status: ExportStatus,
objectKey: string | null = null,
) {
await db
.update(accountExport)
.set({ status, objectKey })
.where(eq(accountExport.id, id));
}
export async function writeExportFile(exportId: string, userId: string) {
const profile = await db.query.user.findFirst({
where: eq(user.id, userId),
columns: { name: true, email: true, createdAt: true },
});
const accounts = await db.query.account.findMany({
where: eq(account.userId, userId),
columns: { providerId: true, createdAt: true },
});
const key = `exports/${userId}/${exportId}.json`;
await env.UPLOADS.put(key, JSON.stringify({ profile, accounts }, null, 2), {
httpMetadata: {
contentType: "application/json",
contentDisposition: 'attachment; filename="account-export.json"',
},
});
return key;
}The export lists its columns explicitly. Auth tables also store credentials such as OAuth tokens and password hashes, so never export whole rows. Add your product's own tables to the file as your data grows.
The object key comes from the customer and export IDs. If the step runs again after a failure, it replaces the same object instead of creating a second file.
Define the Workflow
A Workflow is a class that extends WorkflowEntrypoint and implements run. The kit's conventions avoid classes, but the Workflows runtime requires one. The steps use the same db client and env bindings as the rest of your server code.
import { WorkflowEntrypoint, env } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
import { log } from "@/lib/log";
import {
getExport,
setExportStatus,
writeExportFile,
} from "./account-export.server";
import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers";
export interface AccountExportParams {
exportId: string;
}
export class AccountExportWorkflow extends WorkflowEntrypoint<
Env,
AccountExportParams
> {
async run(event: WorkflowEvent<AccountExportParams>, step: WorkflowStep) {
const { exportId } = event.payload;
let objectKey: string;
try {
objectKey = await step.do(
"write export file",
{ retries: { limit: 3, delay: "30 seconds", backoff: "exponential" } },
async () => {
const record = await getExport(exportId);
if (!record) {
throw new NonRetryableError("Account export not found");
}
return writeExportFile(exportId, record.userId);
},
);
await step.do("mark export ready", () =>
setExportStatus(exportId, "ready", objectKey),
);
} catch (error) {
await step.do("mark export failed", async () => {
log.error("account export failed", { exportId, error });
await setExportStatus(exportId, "failed");
});
throw error;
}
await step.sleep("keep export available", "7 days");
await step.do("delete expired export", async () => {
await env.UPLOADS.delete(objectKey);
await setExportStatus(exportId, "expired");
});
}
}Here is how it applies the rules above:
- Small parameters. The instance receives only the export ID. The file step loads the current record, so it works with current data rather than a copy taken at request time.
- Retries per step. The file step sets its own retry policy. Steps without one retry five times with exponential backoff. A missing record will not appear on a later attempt, so
NonRetryableErrorfails that step immediately. - A visible failure. When the file or ready step fails for good, the
catchblock records the failure in its own step and rethrows. The customer sees a failed export, and the instance stays errored in the dashboard for investigation. Logging inside that step records the failure once. - A long wait.
step.sleepholds the instance for seven days without using CPU. The final step still runs if the customer deletes their account in the meantime, so the file does not outlive its schedule.
Register the Workflow
Cloudflare runs the Workflow from your Worker's entry module, so export the class from src/server.ts, next to the existing request and queue handlers:
export default {
fetch(request: Request) {
// ...
},
queue,
};
export { AccountExportWorkflow } from "@/modules/account-export/account-export.workflow"; Then declare it in wrangler.jsonc:
"workflows": [
{
"name": "my-app-account-export",
"binding": "ACCOUNT_EXPORT",
"class_name": "AccountExportWorkflow"
}
]| Field | Value |
|---|---|
name | The Workflow's name in your Cloudflare account. Names are unique per account, so include your app's name. |
binding | The name your server code uses: env.ACCOUNT_EXPORT. |
class_name | The exported class that implements the Workflow. |
Regenerate the binding types, and restart pnpm dev if it is running:
pnpm cf-typegenThe generated ACCOUNT_EXPORT type reads its parameters from the class, so starting an instance with the wrong payload is a type error. Unlike a queue or bucket, a Workflow has no resource to create in advance: deploying the Worker creates or updates it.
Start an export
Start instances from a protected server function. It resolves the customer from the session, never from input, and uses the new record's ID as the instance ID so you can match one to the other later:
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
import { enforceAuth } from "@/lib/auth/middleware";
import {
createExport,
getLatestExport,
setExportStatus,
} from "./account-export.server";
export const requestAccountExport = createServerFn({ method: "POST" })
.middleware([enforceAuth])
.handler(async ({ context }) => {
const latest = await getLatestExport(context.user.id);
if (latest?.status === "pending") {
return;
}
const exportId = await createExport(context.user.id);
try {
await env.ACCOUNT_EXPORT.create({ id: exportId, params: { exportId } });
} catch (error) {
await setExportStatus(exportId, "failed");
throw error;
}
});
export const getAccountExport = createServerFn({ method: "GET" })
.middleware([enforceAuth])
.handler(async ({ context }) => {
const latest = await getLatestExport(context.user.id);
return latest ?? null;
});While an export is pending, another request reuses it, so repeated clicks do not start parallel exports. Creating the record and starting the instance are separate operations: if Cloudflare rejects the instance, the record is marked failed instead of staying pending. Background jobs follow the same submission guidance.
Like the kit's other account functions, these accept anonymous sessions. If exports should require a registered account, check context.user.isAnonymous as described in access policies.
Serve the file
The kit's protected storage route only serves objects under prefixes that belong to the signed-in customer. In canAccessObject, add the export prefix beside the avatar prefix:
return [`avatars/${context.user.id}/`, `exports/${context.user.id}/`].some(
(prefix) => key.startsWith(prefix),
);The file was stored with an attachment content disposition, so the browser downloads it instead of opening it.
Account deletion already removes the customer's avatars from R2. To remove their exports right away too, list the exports/<user-id>/ prefix in the same cleanup in src/lib/auth/server.ts. Otherwise, each file is still deleted when its Workflow wakes up.
Show the export in settings
The query polls while an export is pending, so the card updates when the file is ready:
import { mutationOptions, queryOptions } from "@tanstack/react-query";
import {
getAccountExport,
requestAccountExport,
} from "./account-export.functions";
export const accountExport = {
queries: {
latest: (userId: string) =>
queryOptions({
queryKey: ["account-export", userId],
queryFn: () => getAccountExport(),
refetchInterval: (query) =>
query.state.data?.status === "pending" ? 3000 : false,
}),
},
mutations: {
request: mutationOptions({
mutationFn: () => requestAccountExport(),
}),
},
};Add the copy to both message catalogs:
"dashboard.settings.export.title": "Export your data",
"dashboard.settings.export.description": "Download a copy of your account data. Each export stays available for 7 days.",
"dashboard.settings.export.request": "Request export",
"dashboard.settings.export.download": "Download export""dashboard.settings.export.title": "Exporta tus datos",
"dashboard.settings.export.description": "Descarga una copia de los datos de tu cuenta. Cada exportación está disponible durante 7 días.",
"dashboard.settings.export.request": "Solicitar exportación",
"dashboard.settings.export.download": "Descargar exportación"The settings card requests an export, shows a pending state, and offers the download once the file is ready:
import { Button, LinkButton } from "@cloudflare/kumo/components/button";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { m } from "@/lib/i18n/messages.js";
import {
SettingsCard,
SettingsCardDescription,
SettingsCardHeader,
SettingsCardTitle,
} from "@/modules/common/settings-card";
import { accountExport } from "./account-export.queries";
interface AccountExportProps {
readonly userId: string;
}
export function AccountExport({ userId }: AccountExportProps) {
const queryClient = useQueryClient();
const options = accountExport.queries.latest(userId);
const latest = useQuery(options);
const request = useMutation({
...accountExport.mutations.request,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: options.queryKey }),
});
const status = latest.data?.status;
const isPending = request.isPending || status === "pending";
return (
<SettingsCard>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<SettingsCardHeader className="min-w-0 flex-1">
<SettingsCardTitle>
{m["dashboard.settings.export.title"]()}
</SettingsCardTitle>
<SettingsCardDescription>
{status === "failed" || request.isError
? m["error.general"]()
: m["dashboard.settings.export.description"]()}
</SettingsCardDescription>
</SettingsCardHeader>
{status === "ready" && latest.data?.objectKey ? (
<LinkButton href={`/api/storage/${latest.data.objectKey}`}>
{m["dashboard.settings.export.download"]()}
</LinkButton>
) : (
<Button
loading={isPending}
disabled={isPending}
onClick={() => request.mutate()}
>
{m["dashboard.settings.export.request"]()}
</Button>
)}
</div>
</SettingsCard>
);
}In src/routes/dashboard/settings/index.tsx, import AccountExport from @/modules/account-export/account-export and render <AccountExport userId={session.user.id} /> above DeleteAccount. The route's loader already provides the session.
A ready export replaces the request button until it expires. A failed one shows the shared error message and lets the customer try again.
Run it locally
Start pnpm dev, open account settings, and select Request export. The Cloudflare Vite plugin runs Workflows locally next to the local D1 database and R2 bucket, and the download appears after a moment.
Open Local Explorer to follow the instance. Its Workflows view lists your instances with each step's status and output, and lets you pause, resume, restart, or terminate one. The R2 view shows the file under exports/.
The local instance also waits seven days before cleanup. To try the cleanup path, temporarily shorten the sleep or use the test in the next step, which skips sleeps.
Test the Workflow
Cloudflare's Vitest integration runs Workflow instances in the kit's Worker test project. A test can skip sleeps, force a step to fail, and wait for the instance's final status.
The Worker project needs an entry module that exports the class, the R2 bucket the Workflow writes to, and the Workflow binding. In the options returned to cloudflareTest, add main, r2Buckets, and workflows. Point main at the Workflow module rather than src/server.ts, which depends on the full TanStack Start build:
return {
main: "./src/modules/account-export/account-export.workflow.ts",
miniflare: {
// ...existing compatibility settings
d1Databases: ["DB"],
r2Buckets: ["UPLOADS"],
workflows: {
ACCOUNT_EXPORT: {
name: "my-app-account-export",
className: "AccountExportWorkflow",
},
},
bindings: {
TEST_MIGRATIONS: migrations,
},
},
};When you have several Workflows, point main at a small test entry that re-exports each class.
The existing configuration passes the D1 migrations to tests as TEST_MIGRATIONS. Declare that binding's type once:
declare namespace Cloudflare {
interface Env {
TEST_MIGRATIONS: import("cloudflare:test").D1Migration[];
}
}The test applies the migrations, creates the records it needs, and checks the successful and failed outcomes:
import { applyD1Migrations, introspectWorkflowInstance } from "cloudflare:test";
import { env } from "cloudflare:workers";
import { eq } from "drizzle-orm";
import { beforeAll, expect, test } from "vitest";
import { db } from "@/db";
import { accountExport, user } from "@/db/schema";
beforeAll(async () => {
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
await db.insert(user).values({
id: "test_user_id",
name: "Test User",
email: "test_user@example.com",
});
});
test("stores the export, then removes it after the retention period", async () => {
await db
.insert(accountExport)
.values({ id: "test_export_id", userId: "test_user_id" });
await using instance = await introspectWorkflowInstance(
env.ACCOUNT_EXPORT,
"test_export_id",
);
await instance.modify(async (m) => {
await m.disableSleeps();
});
await env.ACCOUNT_EXPORT.create({
id: "test_export_id",
params: { exportId: "test_export_id" },
});
await expect(
instance.waitForStepResult({ name: "write export file" }),
).resolves.toBe("exports/test_user_id/test_export_id.json");
await instance.waitForStatus("complete");
const record = await db.query.accountExport.findFirst({
where: eq(accountExport.id, "test_export_id"),
});
expect(record?.status).toBe("expired");
expect(
await env.UPLOADS.get("exports/test_user_id/test_export_id.json"),
).toBeNull();
});
test("marks the export failed when the file cannot be written", async () => {
await db
.insert(accountExport)
.values({ id: "failing_export_id", userId: "test_user_id" });
await using instance = await introspectWorkflowInstance(
env.ACCOUNT_EXPORT,
"failing_export_id",
);
await instance.modify(async (m) => {
await m.disableRetryDelays();
await m.mockStepError(
{ name: "write export file" },
new Error("Storage unavailable"),
);
});
await env.ACCOUNT_EXPORT.create({
id: "failing_export_id",
params: { exportId: "failing_export_id" },
});
await instance.waitForStatus("errored");
const record = await db.query.accountExport.findFirst({
where: eq(accountExport.id, "failing_export_id"),
});
expect(record?.status).toBe("failed");
});Run the Worker project:
pnpm test --project workerdisableSleeps skips the seven-day wait, so the first test reaches the cleanup step right away. The second test makes the file step fail on every attempt, and disableRetryDelays runs those retries without waiting. The Workers runtime prints the errors from the failing instance as uncaught exceptions; that output is expected, and the tests still pass.
Deploy
Release with pnpm deploy or your GitHub integration, and apply the reviewed migration as described in migration deployment. Deploying the Worker creates the Workflow from its workflows declaration. Confirm that it exists:
pnpm wrangler workflows listGive each deployed environment, such as staging and production, its own Workflow name, because names are unique within your Cloudflare account.
Preview environments
A preview does not get its own Workflow. A Workflow binding in the previews block connects to an existing, deployed Workflow and runs that Workflow's code and bindings. If it points at your production Workflow, preview requests start production instances that use production data. Bind previews to a separately deployed non-production Workflow, as Cloudflare's preview resources guide describes.
An instance can stay asleep across many releases. Because a resumed instance replays run against its saved step results, keep step names and result shapes compatible when you change a Workflow that has instances in progress.
Events
step.waitForEvent pauses an instance until your app sends a matching event, such as a customer's confirmation, an administrator's approval, or a provider webhook. A waiting instance uses no CPU time.
For example, you could email the customer a confirmation link and generate the export only after they confirm. Before the file step, the Workflow waits for a day:
try {
await step.waitForEvent("wait for confirmation", {
type: "export-confirmed",
timeout: "1 day",
});
} catch {
await step.do("mark export expired", () =>
setExportStatus(exportId, "expired"),
);
return;
}If no event arrives before the timeout, waitForEvent throws, and the catch block ends the export. A received event's payload holds any data your app sent with it.
Send the event from a protected server function that receives the export ID. Apply the same ownership check as any other change, so a customer can only confirm their own export:
const record = await getExport(data.exportId);
if (record?.userId !== context.user.id || record.status !== "pending") {
return;
}
const instance = await env.ACCOUNT_EXPORT.get(record.id);
await instance.sendEvent({ type: "export-confirmed", payload: {} });While developing, you can also send an event from Local Explorer or with wrangler workflows instances send-event. Cloudflare's events and parameters guide covers event types, payloads, and timeouts.
Schedules
A schedules array on the Workflow declaration creates a new instance on each matching cron expression, without a separate scheduled handler. A nightly maintenance Workflow could be declared like this:
"workflows": [
{
"name": "my-app-nightly-cleanup",
"binding": "NIGHTLY_CLEANUP",
"class_name": "NightlyCleanupWorkflow",
"schedules": ["0 3 * * *"]
}
]NightlyCleanupWorkflow is a class you would add and export like the export Workflow. Its instances receive the matching cron expression and scheduled time on event.schedule. See scheduling a Workflow for the details.
Rollback handlers
When a process changes several systems, a later failure can leave earlier steps half-finished. step.do accepts a rollback handler that undoes a completed step, for example releasing a reservation or deleting a created resource. When the instance fails, Cloudflare runs the registered handlers in reverse order. Sleeping and retrying explains how to register them.
Operations
Inspect deployed instances on your Workflow's page in the Cloudflare dashboard, or with Wrangler:
pnpm wrangler workflows instances list my-app-account-export --status errored
pnpm wrangler workflows instances describe my-app-account-export <instance-id>describe shows each step's status, output, retries, and errors, and when a sleeping instance will wake. Because the instance ID matches the export record's ID, a support request about one export leads straight to its history. The failure log includes the same ID, so you can also search Worker logs for it.
Wrangler can also restart, pause, resume, or terminate an instance. The Wrangler commands reference lists their options.
Cloudflare keeps the history of finished instances for a limited period that depends on your plan, so keep long-lived status in your own records, as the export table does. Workflows are billed for CPU time, requests, stored state, and steps. Check current pricing and limits, including the maximum size of a step result and the number of steps per instance.
Common issues
An export stays pending
Find the instance with the export record's ID using wrangler workflows instances describe. A queued instance is waiting for capacity, a waiting one is sleeping or retrying, and an errored one shows the step that failed. If even the failure step could not update D1, the record stays pending; restart the instance after fixing the cause.
A value is missing after a sleep
The value was set outside a step. After a sleep or restart, run starts again, and only step results are restored. Return the value from a step.do callback and use that result.
env.ACCOUNT_EXPORT has no type
Export the class from src/server.ts before running pnpm cf-typegen. The generated binding type reads its parameters from that export.
A preview fails with workflow.not_found
The preview's binding names a Workflow that has not been deployed. Previews connect to existing Workflows only; deploy the non-production Workflow first, then bind the preview to its name.
How is this guide?
Last updated on
Postgres with Hyperdrive
Connect an existing PostgreSQL database to your Edge app through Cloudflare Hyperdrive, alongside D1, with local development, caching, and previews.
Local development
Common development configuration issues, including environment values, Cloudflare account access, generated files, sign-in, and local email.