Skip to content

Cloudflare Workflows Style Guide

For AI: Workflows are durable execution environments that can hibernate/restart. Steps are cached by name and can retry. Follow these patterns strictly.

Workflows hibernate and lose memory - Any variables outside steps are lost when the engine hibernates (after sleep, long operations, etc.). Only step return values persist.

Steps are cached by name - Step names act as cache keys. Same name = same cached result. Non-deterministic names break caching.

Steps can retry individually - Each step has independent retry logic. Design steps to be self-contained and idempotent.

Instance IDs must be unique forever - Never reuse instance IDs. They’re permanent identifiers for logs/metrics.

Steps may execute multiple times. Always check if work is already done:

// ✅ Correct: Check-then-execute pattern
await step.do('charge customer monthly subscription', async () => {
// Check current state first
const subscription = await fetch(`/api/subscriptions/${customerId}`).then((r) => r.json())
// Exit early if already completed
if (subscription.charged) {
return subscription // Idempotent - safe to run multiple times
}
// Only execute if needed
return await fetch(`/api/charge/${customerId}`, {
method: 'POST',
body: JSON.stringify({ amount: 10.0 }),
}).then((r) => r.json())
})

One external call per step for maximum durability:

// ✅ Correct: Separate steps for separate services
const httpCat = await step.do('get cat from KV', async () => {
return await env.KV.get('cutest-http-cat')
})
const image = await step.do('fetch cat image', async () => {
return await fetch(`https://http.cat/${httpCat}`).then((r) => r.arrayBuffer())
})
// 🔴 Wrong: Multiple services in one step
await step.do('get cat and image', async () => {
const httpCat = await env.KV.get('cutest-http-cat') // If this succeeds...
return fetch(`https://http.cat/${httpCat}`) // ...but this fails, KV is called again on retry
})

Build state exclusively from step returns:

// ✅ Correct: All state from step returns
const userData = await Promise.all([
step.do('fetch user profile', async () => env.KV.get(`user:${userId}`)),
step.do('fetch user preferences', async () =>
env.DB.query('SELECT * FROM prefs WHERE user_id = ?', userId)
),
step.do('fetch user billing', async () => fetch(`/api/billing/${userId}`).then((r) => r.json())),
])
await step.sleep('wait for processing window', '2 hours') // Engine hibernates here
await step.do('process user data', async () => {
// userData is still available - it's built from step returns
return processUserData(userData[0], userData[1], userData[2])
})
// 🔴 Wrong: Variables outside steps
const userCache = [] // Lost after hibernation
await step.do('collect user data', async () => {
userCache.push(await env.KV.get(`user:${userId}`)) // Will be empty after hibernation
})

Step names must be predictable across executions:

// ✅ Correct: Deterministic names
await step.do('fetch user profile', async () => {
/* ... */
})
await step.do(`process order ${orderId}`, async () => {
/* orderId from event.payload */
})
// Dynamic but deterministic iteration
const catList = await step.do('get cat list', async () => env.KV.get('cats'))
for (const cat of catList) {
await step.do(`fetch cat ${cat}`, async () => env.KV.get(cat)) // Deterministic order
}
// 🔴 Wrong: Non-deterministic names
await step.do(`step started at ${Date.now()}`, async () => {
/* ... */
}) // Different every time
await step.do(`random step ${Math.random()}`, async () => {
/* ... */
}) // Never cached

Configure retries per step based on expected failure patterns:

// High-reliability external API
await step.do(
'call payment processor',
{
retries: {
limit: 10,
delay: '30 seconds',
backoff: 'exponential', // 30s, 60s, 120s, 240s...
},
timeout: '5 minutes', // Per attempt timeout
},
async () => {
return await fetch('/api/charge', { method: 'POST' })
}
)
// Quick internal service
await step.do(
'update local cache',
{
retries: { limit: 3, delay: '1 second', backoff: 'constant' },
timeout: '10 seconds',
},
async () => {
return await env.KV.put(cacheKey, data)
}
)

Use for permanent failures (auth errors, invalid data, etc.):

import { NonRetryableError } from 'cloudflare:workflows'
await step.do('validate and process payment', async () => {
const user = await getUser(event.payload.userId)
if (!user) {
// Don't retry - user doesn't exist
throw new NonRetryableError(`User ${event.payload.userId} not found`)
}
if (!user.paymentMethod) {
// Don't retry - missing required data
throw new NonRetryableError(`User ${user.id} has no payment method configured`)
}
// Retryable operations continue normally
return await chargePaymentMethod(user.paymentMethod, amount)
})

Wrap Promise.race/Promise.any in steps for consistent caching:

// ✅ Correct: Race wrapped in step
const winner = await step.do('race multiple providers', async () => {
return await Promise.race([
step.do('provider a', async () => fetchFromProviderA()),
step.do('provider b', async () => fetchFromProviderB()),
step.do('provider c', async () => fetchFromProviderC()),
])
})
// Result is deterministically cached - same winner on retry/hibernation
// 🔴 Wrong: Unwrapped race
const winner = await Promise.race([
step.do('provider a', async () => fetchFromProviderA()),
step.do('provider b', async () => fetchFromProviderB()),
]) // Result varies across workflow lifetimes

Instance IDs are permanent identifiers - never reuse:

// ✅ Correct: Always unique
const instanceId = `user-${userId}-${crypto.randomUUID()}`
await env.WORKFLOW.create({ id: instanceId, params: userData })
// Or use existing unique identifiers
const instanceId = `order-${orderId}` // If orderIds are globally unique
await env.WORKFLOW.create({ id: instanceId, params: orderData })
// 🔴 Wrong: Reusing user IDs
await env.WORKFLOW.create({ id: userId, params: userData }) // Fails if user triggers workflow twice

For multiple instances, use createBatch for better throughput:

// ✅ Correct: Batch creation
const instances = users.map((user) => ({
id: `user-welcome-${user.id}-${Date.now()}`,
params: { userId: user.id, email: user.email },
}))
await env.WELCOME_WORKFLOW.createBatch(instances)
// 🔴 Wrong: Sequential creation
for (const user of users) {
await env.WELCOME_WORKFLOW.create({
id: `user-welcome-${user.id}`,
params: { userId: user.id },
}) // Slow, may hit rate limits
}

Use waitForEvent for external triggers during workflow execution:

export class PaymentWorkflow extends WorkflowEntrypoint<Env, PaymentParams> {
async run(event: WorkflowEvent<PaymentParams>, step: WorkflowStep) {
// Process initial payment request
await step.do('create payment intent', async () => {
return await createStripePaymentIntent(event.payload.amount)
})
// Wait for webhook confirmation
const webhookEvent = await step.waitForEvent('wait for payment confirmation', {
type: 'stripe-payment-succeeded',
timeout: '10 minutes', // Fail if no webhook received
})
// Continue processing
await step.do('fulfill order', async () => {
return await fulfillOrder(event.payload.orderId, webhookEvent.payload)
})
}
}

Match event types exactly between waitForEvent and sendEvent:

// In webhook handler
export default {
async fetch(req: Request, env: Env) {
const webhookData = await req.json()
const instanceId = webhookData.metadata.workflowInstanceId
const instance = await env.PAYMENT_WORKFLOW.get(instanceId)
await instance.sendEvent({
type: 'stripe-payment-succeeded', // Must match waitForEvent type
payload: webhookData,
})
return new Response('OK')
},
}

Define strong types for workflow parameters and events:

interface OrderProcessingParams {
orderId: string
customerId: string
items: Array<{ sku: string; quantity: number; price: number }>
shippingAddress: Address
}
interface PaymentWebhookEvent {
paymentIntentId: string
status: 'succeeded' | 'failed'
amount: number
metadata: Record<string, string>
}
export class OrderWorkflow extends WorkflowEntrypoint<Env, OrderProcessingParams> {
async run(event: WorkflowEvent<OrderProcessingParams>, step: WorkflowStep) {
// event.payload is typed as OrderProcessingParams
const order = await step.do('create order record', async () => {
return await env.DB.insertOrder({
id: event.payload.orderId,
customerId: event.payload.customerId,
items: event.payload.items,
})
})
// Strongly typed event waiting
const payment = await step.waitForEvent<PaymentWebhookEvent>('wait for payment confirmation', {
type: 'payment-webhook',
timeout: '15 minutes',
}) // payment.payload is typed as PaymentWebhookEvent
if (payment.payload.status === 'succeeded') {
await step.do('ship order', async () => {
return await scheduleShipping(order.id, event.payload.shippingAddress)
})
}
}
}

When conditional logic requires different step paths, use nested steps to maintain state consistency:

// ✅ Correct: Nested steps maintain proper state flow
const phoneNumber = await step.do('provision workspace phone number', async () => {
// Check if already exists (idempotency)
const existing = await checkExistingNumber(workspaceId)
if (existing) return existing
// Nested conditional logic with sub-steps
const poolNumber = await step.do('check phone number pool', async () => {
return await getPooledNumber(areaCode)
})
if (poolNumber) {
return await step.do('assign pooled number', async () => {
return await assignPoolNumber(poolNumber.sid, workspaceId)
})
} else {
return await step.do('purchase new number', async () => {
const available = await searchAvailableNumbers(areaCode)
return await purchaseNumber(available[0], workspaceId)
})
}
})
// 🔴 Wrong: Variables assigned outside steps
let phoneData: any // Lost on hibernation
const poolNumber = await step.do('check pool', async () => getPooledNumber())
if (poolNumber) {
phoneData = await step.do('assign', async () => assignNumber()) // phoneData lost if hibernation occurs
}

Never assign variables outside of steps - Only step returns persist across hibernation:

// 🔴 Wrong: Variables assigned outside steps
let userData: any
let processedData: any
userData = await step.do('fetch user', async () => getUser())
processedData = processUser(userData) // Lost if hibernation occurs here
await step.do('save processed', async () => saveData(processedData))
// ✅ Correct: All state from step returns
const userData = await step.do('fetch user', async () => getUser())
const processedData = await step.do('process user data', async () => {
return processUser(userData) // userData persists, result persists
})

Never mutate event.payload - It’s immutable and changes are lost:

// 🔴 Wrong
event.payload.processed = true // Lost after step completes

Never use unawaited steps - Creates race conditions:

// 🔴 Wrong
step.do('background task', async () => processData()) // Promise ignored
await step.do('dependent task', async () => useProcessedData()) // May run before background task

Never store workflow state in class properties:

// 🔴 Wrong
export class MyWorkflow extends WorkflowEntrypoint {
private userData: any // Lost on hibernation
async run(event, step) {
this.userData = await step.do('fetch user', async () => getUser())
await step.sleep('wait', '1 hour') // userData is now undefined
}
}

Never use intermediate variables for step coordination:

// 🔴 Wrong: Intermediate variables break hibernation
let phoneNumber: string
let twilioSid: string
if (pooledNumber) {
const result = await step.do('assign pooled', async () => assignNumber())
phoneNumber = result.phone_number // Lost on hibernation
twilioSid = result.sid // Lost on hibernation
} else {
const result = await step.do('purchase new', async () => buyNumber())
phoneNumber = result.phone_number // Lost on hibernation
twilioSid = result.sid // Lost on hibernation
}
await step.do('save to database', async () => {
return saveNumber(phoneNumber, twilioSid) // Variables may be undefined
})
// ✅ Correct: Single step handles full conditional flow
const phoneData = await step.do('provision phone number', async () => {
if (pooledNumber) {
return await assignNumber(pooledNumber)
} else {
return await purchaseNumber(areaCode)
}
})
await step.do('save to database', async () => {
return saveNumber(phoneData.phone_number, phoneData.sid)
})

Never use current time/randomness in step names:

// 🔴 Wrong - breaks caching
await step.do(`process-${Date.now()}`, async () => {
/* ... */
})
await step.do(`task-${Math.random()}`, async () => {
/* ... */
})

Following these patterns ensures your Workflows are resilient, debuggable, and perform correctly across hibernation cycles and retries.