Cloudflare Workflows Style Guide
Cloudflare Workflows Style Guide
Section titled “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.
Critical Architecture Concepts
Section titled “Critical Architecture Concepts”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.
Step Design Patterns
Section titled “Step Design Patterns”Idempotency Pattern
Section titled “Idempotency Pattern”Steps may execute multiple times. Always check if work is already done:
// ✅ Correct: Check-then-execute patternawait 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())})Granular Steps Pattern
Section titled “Granular Steps Pattern”One external call per step for maximum durability:
// ✅ Correct: Separate steps for separate servicesconst 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 stepawait 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})State Management Pattern
Section titled “State Management Pattern”Build state exclusively from step returns:
// ✅ Correct: All state from step returnsconst 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 stepsconst userCache = [] // Lost after hibernationawait step.do('collect user data', async () => { userCache.push(await env.KV.get(`user:${userId}`)) // Will be empty after hibernation})Deterministic Naming Pattern
Section titled “Deterministic Naming Pattern”Step names must be predictable across executions:
// ✅ Correct: Deterministic namesawait step.do('fetch user profile', async () => { /* ... */})await step.do(`process order ${orderId}`, async () => { /* orderId from event.payload */})
// Dynamic but deterministic iterationconst 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 namesawait step.do(`step started at ${Date.now()}`, async () => { /* ... */}) // Different every timeawait step.do(`random step ${Math.random()}`, async () => { /* ... */}) // Never cachedError Handling
Section titled “Error Handling”Retry Configuration
Section titled “Retry Configuration”Configure retries per step based on expected failure patterns:
// High-reliability external APIawait 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 serviceawait step.do( 'update local cache', { retries: { limit: 3, delay: '1 second', backoff: 'constant' }, timeout: '10 seconds', }, async () => { return await env.KV.put(cacheKey, data) })Non-Retryable Errors
Section titled “Non-Retryable Errors”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)})Concurrency and Racing
Section titled “Concurrency and Racing”Wrap Promise.race/Promise.any in steps for consistent caching:
// ✅ Correct: Race wrapped in stepconst 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 raceconst winner = await Promise.race([ step.do('provider a', async () => fetchFromProviderA()), step.do('provider b', async () => fetchFromProviderB()),]) // Result varies across workflow lifetimesInstance Management
Section titled “Instance Management”Unique Instance IDs
Section titled “Unique Instance IDs”Instance IDs are permanent identifiers - never reuse:
// ✅ Correct: Always uniqueconst instanceId = `user-${userId}-${crypto.randomUUID()}`await env.WORKFLOW.create({ id: instanceId, params: userData })
// Or use existing unique identifiersconst instanceId = `order-${orderId}` // If orderIds are globally uniqueawait env.WORKFLOW.create({ id: instanceId, params: orderData })
// 🔴 Wrong: Reusing user IDsawait env.WORKFLOW.create({ id: userId, params: userData }) // Fails if user triggers workflow twiceBatch Creation
Section titled “Batch Creation”For multiple instances, use createBatch for better throughput:
// ✅ Correct: Batch creationconst 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 creationfor (const user of users) { await env.WELCOME_WORKFLOW.create({ id: `user-welcome-${user.id}`, params: { userId: user.id }, }) // Slow, may hit rate limits}Event Handling
Section titled “Event Handling”Waiting for Events
Section titled “Waiting for Events”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) }) }}Sending Events
Section titled “Sending Events”Match event types exactly between waitForEvent and sendEvent:
// In webhook handlerexport 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') },}TypeScript Integration
Section titled “TypeScript Integration”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) }) } }}Nested Steps Pattern
Section titled “Nested Steps Pattern”When conditional logic requires different step paths, use nested steps to maintain state consistency:
// ✅ Correct: Nested steps maintain proper state flowconst 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 stepslet phoneData: any // Lost on hibernationconst poolNumber = await step.do('check pool', async () => getPooledNumber())if (poolNumber) { phoneData = await step.do('assign', async () => assignNumber()) // phoneData lost if hibernation occurs}Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”Never assign variables outside of steps - Only step returns persist across hibernation:
// 🔴 Wrong: Variables assigned outside stepslet userData: anylet processedData: anyuserData = await step.do('fetch user', async () => getUser())processedData = processUser(userData) // Lost if hibernation occurs hereawait step.do('save processed', async () => saveData(processedData))
// ✅ Correct: All state from step returnsconst 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:
// 🔴 Wrongevent.payload.processed = true // Lost after step completesNever use unawaited steps - Creates race conditions:
// 🔴 Wrongstep.do('background task', async () => processData()) // Promise ignoredawait step.do('dependent task', async () => useProcessedData()) // May run before background taskNever store workflow state in class properties:
// 🔴 Wrongexport 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 hibernationlet phoneNumber: stringlet 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 flowconst 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 cachingawait 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.