| name | cloudflare-workflows |
| description | Build durable workflows with Cloudflare Workflows (GA April 2025). Features step.do, step.sleep, waitForEvent,
Vitest testing, and runs for hours to days with automatic retries and state persistence.
Use when: creating long-running workflows, implementing retry logic, building event-driven processes,
testing workflows with cloudflare:test, coordinating API calls, or troubleshooting NonRetryableError,
I/O context errors, serialization failures.
Keywords: cloudflare workflows, workflows workers, durable execution, workflow step,
WorkflowEntrypoint, step.do, step.sleep, workflow retries, NonRetryableError,
workflow state, wrangler workflows, workflow events, long-running tasks, step.sleepUntil,
step.waitForEvent, workflow bindings, vitest testing, cloudflare:test, introspectWorkflowInstance
|
Cloudflare Workflows
Status: Production Ready ✅ (GA since April 2025)
Last Updated: 2025-11-25
Dependencies: cloudflare-worker-base (for Worker setup)
Latest Versions: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0
Recent Updates (2025):
- April 2025: Workflows GA release - waitForEvent API, Vitest testing, CPU time metrics, 4,500 concurrent instances
- October 2025: Instance creation rate 10x faster (100/sec), concurrency increased to 10,000
- 2025 Limits: Max steps 1,024, state persistence 1MB/step (100MB-1GB per instance), event payloads 1MB, CPU time 5 min max
- Testing: cloudflare:test module with introspectWorkflowInstance, disableSleeps, mockStepResult, mockEvent modifiers
- Platform: Waiting instances don't count toward concurrency, retention 3-30 days, subrequests 50-1,000
Quick Start (5 Minutes)
npm create cloudflare@latest my-workflow -- --template cloudflare/workflows-starter --git --deploy false
cd my-workflow
{
"name": "my-workflow",
"main": "src/index.ts",
"compatibility_date": "2025-11-25",
"workflows": [{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow"
}]
}
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const result = await step.do('process', async () => { /* work */ });
await step.sleep('wait', '1 hour');
await step.do('continue', async () => { /* more work */ });
}
}
npm run deploy
npx wrangler workflows instances list my-workflow
CRITICAL: Extends WorkflowEntrypoint, implements run() with step methods, bindings in wrangler.jsonc
Step Methods
step.do() - Execute Work
step.do<T>(name: string, config?: WorkflowStepConfig, callback: () => Promise<T>): Promise<T>
Parameters:
name - Step name (for observability)
config (optional) - Retry configuration (retries, timeout, backoff)
callback - Async function that does the work
Returns: Value from callback (must be serializable)
Example:
const result = await step.do('call API', { retries: { limit: 10, delay: '10s', backoff: 'exponential' }, timeout: '5 min' }, async () => {
return await fetch('https://api.example.com/data').then(r => r.json());
});
CRITICAL - Serialization:
- ✅ Allowed: string, number, boolean, Array, Object, null
- ❌ Forbidden: Function, Symbol, circular references, undefined
- Throws error if return value isn't JSON serializable
step.sleep() - Relative Sleep
step.sleep(name: string, duration: WorkflowDuration): Promise<void>
Parameters:
name - Step name
duration - Number (ms) or string: "second", "minute", "hour", "day", "week", "month", "year" (plural forms accepted)
Examples:
await step.sleep('wait 5 minutes', '5 minutes');
await step.sleep('wait 1 hour', '1 hour');
await step.sleep('wait 2 days', '2 days');
await step.sleep('wait 30 seconds', 30000);
Note: Resuming workflows take priority over new instances. Sleeps don't count toward step limits.
step.sleepUntil() - Sleep to Specific Date
step.sleepUntil(name: string, timestamp: Date | number): Promise<void>
Parameters:
name - Step name
timestamp - Date object or UNIX timestamp (milliseconds)
Examples:
await step.sleepUntil('wait for launch', new Date('2025-12-25T00:00:00Z'));
await step.sleepUntil('wait until time', Date.parse('24 Oct 2024 13:00:00 UTC'));
step.waitForEvent() - Wait for External Event (GA April 2025)
step.waitForEvent<T>(name: string, options: { type: string; timeout?: string | number }): Promise<T>
Parameters:
name - Step name
options.type - Event type to match
options.timeout (optional) - Max wait time (default: 24 hours, max: 30 days)
Returns: Event payload sent via instance.sendEvent()
Example:
export class PaymentWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
await step.do('create payment', async () => { });
const webhookData = await step.waitForEvent<StripeWebhook>(
'wait for payment confirmation',
{ type: 'stripe-webhook', timeout: '1 hour' }
);
if (webhookData.status === 'succeeded') {
await step.do('fulfill order', async () => { });
}
}
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.url.includes()) {
instance = env..(instanceId);
instance.({ : , : req.() });
();
}
}
};
Timeout handling:
try {
const event = await step.waitForEvent('wait for user', { type: 'user-submitted', timeout: '10 minutes' });
} catch (error) {
await step.do('send reminder', async () => { });
}
WorkflowStepConfig
interface WorkflowStepConfig {
retries?: {
limit: number;
delay: string | number;
backoff?: 'constant' | 'linear' | 'exponential';
};
timeout?: string | number;
}
Default: { retries: { limit: 5, delay: 10000, backoff: 'exponential' }, timeout: '10 minutes' }
Backoff Examples:
{ retries: { limit: 3, delay: '30 seconds', backoff: 'constant' } }
{ retries: { limit: 5, delay: '1 minute', backoff: 'linear' } }
{ retries: { limit: 10, delay: '10 seconds', backoff: 'exponential' }, timeout: '5 minutes' }
{ retries: { limit: Infinity, delay: '1 minute', backoff: 'exponential' } }
{ retries: { limit: 0 } }
Error Handling
NonRetryableError
Force workflow to fail immediately without retrying:
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
import { NonRetryableError } from 'cloudflare:workflows';
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
await step.do('validate input', async () => {
if (!event.payload.userId) {
throw new NonRetryableError('userId is required');
}
const user = await this.env.DB.prepare(
'SELECT * FROM users WHERE id = ?'
).bind(event.payload.userId).first();
if (!user) {
();
}
user;
});
}
}
When to use NonRetryableError:
- ✅ Authentication/authorization failures
- ✅ Invalid input that won't change
- ✅ Resource doesn't exist (404)
- ✅ Validation errors
- ❌ Network failures (should retry)
- ❌ Rate limits (should retry with backoff)
- ❌ Temporary service outages (should retry)
Catch Errors to Continue Workflow
Prevent workflow failure by catching optional step errors:
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
await step.do('process payment', async () => { });
try {
await step.do('send email', async () => { });
} catch (error) {
await step.do('log failure', async () => {
await this.env.DB.prepare('INSERT INTO failed_emails VALUES (?, ?)').bind(event.payload.userId, error.message).run();
});
}
await step.do('update status', async () => { });
}
}
Graceful Degradation:
let result;
try {
result = await step.do('call primary API', async () => await callPrimaryAPI());
} catch {
result = await step.do('call backup API', async () => await callBackupAPI());
}
Triggering Workflows
Configure binding (wrangler.jsonc):
{
"workflows": [{
"name": "my-workflow",
"binding": "MY_WORKFLOW",
"class_name": "MyWorkflow",
"script_name": "workflow-worker"
}]
}
Trigger from Worker:
const instance = await env.MY_WORKFLOW.create({ params: { userId: '123' } });
return Response.json({ id: instance.id, status: await instance.status() });
Instance Management:
const instance = await env.MY_WORKFLOW.get(instanceId);
const status = await instance.status();
await instance.sendEvent({ type: 'user-action', payload: { action: 'approved' } });
await instance.pause();
await instance.resume();
await instance.terminate();
State Persistence
Workflows automatically persist state returned from step.do():
✅ Serializable:
- Primitives:
string, number, boolean, null
- Arrays, Objects, Nested structures
❌ Non-Serializable:
- Functions, Symbols, circular references, undefined, class instances
Example:
const result = await step.do('fetch data', async () => ({
users: [{ id: 1, name: 'Alice' }],
timestamp: Date.now(),
metadata: null
}));
const bad = await step.do('bad', async () => ({ data: [1, 2, 3], transform: (x) => x * 2 }));
Access State Across Steps:
const userData = await step.do('fetch user', async () => ({ id: 123, email: 'user@example.com' }));
const orderData = await step.do('create order', async () => ({ userId: userData.id, orderId: 'ORD-456' }));
await step.do('send email', async () => sendEmail({ to: userData.email, subject: `Order ${orderData.orderId}` }));
Observability
Built-in Metrics (Enhanced in 2025)
Workflows automatically track:
- Instance status: queued, running, complete, errored, paused, waiting
- Step execution: start/end times, duration, success/failure
- Retry history: attempts, errors, delays
- Sleep state: when workflow will wake up
- Output: return values from steps and run()
- CPU time (GA April 2025): Active processing time per instance for billing insights
View Metrics in Dashboard
Access via Cloudflare dashboard:
- Workers & Pages
- Select your workflow
- View instances and metrics
Metrics include:
- Total instances created
- Success/error rates
- Average execution time
- Step-level performance
- CPU time consumption (2025 feature)
Programmatic Access
const instance = await env.MY_WORKFLOW.get(instanceId);
const status = await instance.status();
console.log(status);
CPU Time Configuration (2025):
{ "limits": { "cpu_ms": 300000 } }
Limits (Updated 2025)
| Feature | Workers Free | Workers Paid |
|---|
| Max steps per workflow | 1,024 | 1,024 |
| Max state per step | 1 MiB | 1 MiB |
| Max state per instance | 100 MB | 1 GB |
| Max event payload size | 1 MiB | 1 MiB |
| Max sleep/sleepUntil duration | 365 days | 365 days |
| Max waitForEvent timeout | 365 days | 365 days |
| CPU time per step | 10 ms | 30 sec (default), 5 min (max) |
| Duration (wall clock) per step | Unlimited | Unlimited |
| Max workflow executions | 100,000/day | Unlimited |
| Concurrent instances | 25 | 10,000 (Oct 2025, up from 4,500) |
| Instance creation rate | 100/second | 100/second (Oct 2025, 10x faster) |
| Max queued instances | 100,000 | 1,000,000 |
| Max subrequests per instance | 50/request | 1,000/request |
| Retention (completed state) | 3 days | 30 days |
| Max Workflow name length | 64 chars | 64 chars |
| Max instance ID length | 100 chars | 100 chars |
CRITICAL Notes:
step.sleep() and step.sleepUntil() do NOT count toward 1,024 step limit
- Waiting instances (sleeping, retrying, or waiting for events) do NOT count toward concurrency limits
- Instance creation rate increased 10x (October 2025): 100 per 10 seconds → 100 per second
- Max concurrency increased (October 2025): 4,500 → 10,000 concurrent instances
- State persistence limits increased (2025): 128 KB → 1 MiB per step, 100 MB - 1 GB per instance
- Event payload size increased (2025): 128 KB → 1 MiB
- CPU time configurable via
wrangler.jsonc: { "limits": { "cpu_ms": 300000 } } (5 min max)
Pricing
Requires Workers Paid plan ($5/month)
Workflow Executions:
- First 10,000,000 step executions/month: FREE
- After that: $0.30 per million step executions
What counts as a step execution:
- Each
step.do() call
- Each retry of a step
step.sleep(), step.sleepUntil(), step.waitForEvent() do NOT count
Cost examples:
- Workflow with 5 steps, no retries: 5 step executions
- Workflow with 3 steps, 1 step retries 2 times: 5 step executions (3 + 2)
- 10M simple workflows/month (5 steps each): ((50M - 10M) / 1M) × $0.30 = $12/month
Troubleshooting
Issue: "Cannot perform I/O on behalf of a different request"
Cause: Trying to use I/O objects created in one request context from another request handler
Solution: Always perform I/O within step.do() callbacks
const response = await fetch('https://api.example.com/data');
const data = await response.json();
await step.do('use data', async () => {
return data;
});
const data = await step.do('fetch data', async () => {
const response = await fetch('https://api.example.com/data');
return await response.json();
});
Issue: NonRetryableError behaves differently in dev vs production
Known Issue: Throwing NonRetryableError with empty message in dev mode causes retries, but works correctly in production
Workaround: Always provide a message to NonRetryableError
throw new NonRetryableError();
throw new NonRetryableError('User not found');
Source: workers-sdk#10113
Vitest Testing (GA April 2025)
Workflows support full testing integration via cloudflare:test module.
Setup
npm install -D vitest@latest @cloudflare/vitest-pool-workers@latest
vitest.config.ts:
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({ test: { poolOptions: { workers: { miniflare: { bindings: { MY_WORKFLOW: { scriptName: 'workflow' } } } } } } });
Introspection API
import { env, introspectWorkflowInstance } from 'cloudflare:test';
it('should complete workflow', async () => {
const instance = await introspectWorkflowInstance(env.MY_WORKFLOW, 'test-123');
try {
await instance.modify(async (m) => {
await m.disableSleeps();
await m.mockStepResult({ name: 'fetch data' }, { users: [{ id: 1 }] });
await m.mockEvent({ type: 'approval', payload: { approved: true } });
await m.mockStepError({ name: 'call API' }, new Error('Network timeout'), 1);
});
await env.MY_WORKFLOW.create({ id: 'test-123' });
await expect(instance.())...();
} {
instance.();
}
});
Test Modifiers
disableSleeps(steps?) - Skip sleeps instantly
mockStepResult(step, result) - Mock step.do() result
mockStepError(step, error, times?) - Force step.do() to throw
mockEvent(event) - Send mock event to step.waitForEvent()
forceStepTimeout(step, times?) - Force step.do() timeout
forceEventTimeout(step) - Force step.waitForEvent() timeout
Official Docs: https://developers.cloudflare.com/workers/testing/vitest-integration/
Related Documentation
Last Updated: 2025-11-25
Version: 1.0.0
Maintainer: Jeremy Dawes | jeremy@jezweb.net