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 cachedAvoid Side Effects Outside step.do
Section titled “Avoid Side Effects Outside step.do”Code outside steps may be re-executed on engine restart. Only put side-effect-free or deterministic logic outside steps:
// 🔴 Wrong: Side effects outside steps get duplicated on replayconsole.log('Workflow started') // Logged multiple times on restartconst badInstance = await this.env.ANOTHER_WORKFLOW.create() // Creates duplicate instancesconst badTimestamp = new Date().toISOString() // Different value on each replay
// 🔴 Wrong: Non-deterministic values outside steps cause divergent pathsconst badRandom = Math.random()if (badRandom > 0.5) { await step.do('conditional step', async () => { /* ... */ }) // May or may not run depending on the replay}
// ✅ Correct: Wrap side effects and non-deterministic values in stepsconst startedAt = await step.do('record start time', async () => { return new Date().toISOString() // Captured once, cached on replay})
const goodInstance = await step.do('create child workflow', async () => { return await this.env.ANOTHER_WORKFLOW.create({ id: uniqueId, params })})
// ✅ OK: Non-serializable resource creation is fine outside steps// (DB connections, clients) -- these are recreated on each engine lifetimeconst db = createDBConnection(this.env.DB_URL, this.env.DB_TOKEN)
// ✅ OK: Deterministic validation that throws NonRetryableErrorconst parsed = mySchema.safeParse(event.payload)if (!parsed.success) { throw new NonRetryableError(`Invalid params: ${parsed.error.message}`)}Use Conditional Logic Carefully
Section titled “Use Conditional Logic Carefully”Conditions outside steps must be based on deterministic values – either event.payload or step return values. Non-deterministic conditions (Math.random(), Date.now()) cause different execution paths on replay:
const config = await step.do('fetch config', async () => { return await this.env.KV.get('feature-flags', { type: 'json' })})
// ✅ Correct: Condition based on step output (deterministic)if (config.enableNotifications) { await step.do('send notification', async () => { /* ... */ })}
// ✅ Correct: Condition based on event payload (deterministic)if (event.payload.userType === 'premium') { await step.do('premium processing', async () => { /* ... */ })}
// 🔴 Wrong: Non-deterministic condition outside a stepif (Math.random() > 0.5) { await step.do('maybe run', async () => { /* ... */ }) // Different result on replay}
// ✅ Correct: Wrap non-deterministic values in a step firstconst shouldProcess = await step.do('decide randomly', async () => Math.random() > 0.5)if (shouldProcess) { await step.do('conditionally process', async () => { /* ... */ })}Error 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}Limits and Constraints
Section titled “Limits and Constraints”Step Timeouts: 30 Minutes Max
Section titled “Step Timeouts: 30 Minutes Max”Step timeouts must be 30 minutes or less. For longer waits, use step.waitForEvent():
// ✅ Correct: Timeout within the 30-minute limitawait step.do('call external api', { timeout: '5 minutes' }, async () => { return await fetch('/api/long-running-task')})
// 🔴 Wrong: Timeout exceeds 30 minutesawait step.do( 'wait for processing', { timeout: '2 hours' }, // Will not work as expected async () => { return await pollUntilComplete() })
// ✅ Correct: Use waitForEvent for long waitsawait step.do('start async processing', async () => { return await triggerExternalJob(jobId) // External system will send event when done})
const result = await step.waitForEvent('wait for job completion', { type: 'job-completed', timeout: '4 hours', // waitForEvent supports longer durations})Step Return Values: 1 MiB Max
Section titled “Step Return Values: 1 MiB Max”Each step can persist up to 1 MiB (2^20 bytes) of state. Steps returning larger data will fail. Store large data externally and return a reference:
// 🔴 Wrong: Returning a large response that may exceed 1 MiBconst largeData = await step.do('fetch large dataset', async () => { const response = await fetch('https://api.example.com/large-dataset') return await response.json() // Could exceed 1 MiB})
// ✅ Correct: Store in R2/KV, return a referenceconst dataRef = await step.do('fetch and store large dataset', async () => { const response = await fetch('https://api.example.com/large-dataset') const data = await response.json() await this.env.BUCKET.put(`dataset-${jobId}`, JSON.stringify(data)) return { key: `dataset-${jobId}` } // Small reference, well under 1 MiB})
// Retrieve in a later step when neededawait step.do('process dataset', async () => { const stored = await this.env.BUCKET.get(dataRef.key) return processData(await stored!.json())})Recommended Patterns
Section titled “Recommended Patterns”Capture Non-Deterministic Values in Steps
Section titled “Capture Non-Deterministic Values in Steps”Timestamps, UUIDs, and any other non-deterministic values must be captured inside steps so they are cached and consistent across replays:
// ✅ Correct: Timestamp captured in a dedicated stepconst startedAt = await step.do('capture start timestamp', async () => { return new Date().toISOString()})
// ✅ Correct: UUID generated inside a stepconst generationId = await step.do('generate unique id', async () => { return crypto.randomUUID()})
// Then use these deterministic values in subsequent stepsawait step.do('create record', async () => { return await createRecord({ id: generationId, startedAt })})
// 🔴 Wrong: Non-deterministic values outside stepsconst startedAt = new Date().toISOString() // Different on every replayconst id = crypto.randomUUID() // New UUID generated on every replayLoop Steps for Batch Processing
Section titled “Loop Steps for Batch Processing”Create one step per item in a loop for independent resumability. If the workflow restarts, completed items are skipped via step caching:
// ✅ Correct: One step per item -- each is independently cached and resumableconst images = await step.do('get image list', async () => getImages())
const results = []for (let i = 0; i < images.length; i++) { const result = await step.do(`categorize image ${i + 1}`, async () => { return await categorizeWithAI(images[i]) }) results.push(result)}
// For large batches, add sleep between groups to avoid rate limitsconst BATCH_SIZE = 10for (let i = 0; i < items.length; i += BATCH_SIZE) { const batch = items.slice(i, i + BATCH_SIZE) for (const item of batch) { await step.do(`process ${item.id}`, async () => processItem(item)) } if (i + BATCH_SIZE < items.length) { await step.sleep('rate limit pause', '5 seconds') }}
// 🔴 Wrong: All items in one step -- no per-item resumability, risks timeoutawait step.do('process all images', async () => { return await Promise.all(images.map((img) => categorizeWithAI(img)))})Best-Effort Steps with .catch()
Section titled “Best-Effort Steps with .catch()”For non-critical steps (logging, timestamp updates, analytics), attach .catch() so their failure doesn’t abort the workflow:
// ✅ Correct: Core steps propagate errors; auxiliary steps are best-effortconst metrics = await step.do('fetch campaign metrics', RETRY_EXTERNAL, async () => { return await fetchMetrics(campaignId, dateRange)})
await step.do('persist metrics', RETRY_STANDARD, async () => { return await saveMetrics(campaignId, metrics)})
// Best-effort: timestamp and logging failures don't kill the workflowawait step .do('update last synced timestamp', RETRY_QUICK, async () => { return await updateLastSyncedAt(campaignId) }) .catch(() => { // Swallowed intentionally -- non-critical })
await step .do('log sync success', RETRY_QUICK, async () => { return await logSyncOperation(campaignId, 'success') }) .catch(() => { // Swallowed intentionally -- non-critical })Continue-on-Failure for Cleanup Workflows
Section titled “Continue-on-Failure for Cleanup Workflows”Deletion and teardown workflows should attempt all cleanup steps even if some fail. Track failures and throw after all steps have run:
// ✅ Correct: Continue-on-failure pattern for cleanupconst cleanupSteps = [ releasePhoneNumbers(ctx), releaseEmailAddresses(ctx), deleteCallRailRecordings(ctx), deleteWorkspaceData(ctx),]
const failedSteps: string[] = []
for (const stepDef of cleanupSteps) { try { await step.do(stepDef.name, stepDef.config, stepDef.fn) } catch { failedSteps.push(stepDef.name) // Continue to next step -- don't halt cleanup }}
if (failedSteps.length > 0) { // Throw after all steps attempted so the workflow is marked as failed throw new Error(`Cleanup partially failed. Failed steps: ${failedSteps.join(', ')}`)}Pure Computation Outside Steps
Section titled “Pure Computation Outside Steps”Pure functions (no I/O, no side effects) are safe to call between steps because they produce the same result on every replay. Only I/O needs to be wrapped in step.do():
// ✅ Correct: Pure transformations between steps are fineconst rawMetrics = await step.do('fetch metrics', async () => { return await fetchMetricsFromPlatform(campaignId, dateRange)})
// Pure computation -- deterministic, no side effects, safe outside step.doconst processedMetrics = processMetricsSyncData(rawMetrics)const dailyBreakdown = aggregateDailyMetrics(processedMetrics)const weeklyRollup = aggregateDailyToWeekly(dailyBreakdown)
// Only I/O goes inside stepsawait step.do('persist processed metrics', async () => { return await saveMetrics(campaignId, weeklyRollup)})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 () => { /* ... */})Never wrap your entire workflow in a top-level try/catch — This swallows errors and prevents Workflows from marking instances as failed. Failed instances become invisible in the dashboard, logs, and metrics. You lose retries, alerting, and the ability to diagnose production issues:
// 🔴 Wrong: Top-level try/catch hides failures from the Workflows engineexport class MyWorkflow extends WorkflowEntrypoint<Env, Params> { async run(event: WorkflowEvent<Params>, step: WorkflowStep) { try { await step.do('fetch data', async () => fetchData()) await step.do('process data', async () => processData()) await step.do('save results', async () => saveResults()) } catch (error) { // Instance shows as "complete" even though it failed // No failed instance in dashboard, no retry, no alert await step.do('set error status', async () => { await updateStatus(event.payload.id, 'error') }) // Error is swallowed -- workflow "succeeds" from the engine's perspective } }}
// ✅ Correct: Let errors propagate so the engine marks the instance as failedexport class MyWorkflow extends WorkflowEntrypoint<Env, Params> { async run(event: WorkflowEvent<Params>, step: WorkflowStep) { await step.do('fetch data', async () => fetchData()) await step.do('process data', async () => processData()) await step.do('save results', async () => saveResults()) // If any step exhausts its retries, the workflow fails visibly }}
// ✅ Correct: Handle cleanup within individual steps, not at the workflow level// Use NonRetryableError for permanent failures, and step-level retries for transient ones.// If you need to update external state on failure, do it in the step that fails// or use a separate monitoring system that watches for failed workflow instances.await step.do( 'process payment', { retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' } }, async () => { const result = await chargeCustomer(customerId) if (result.permanentFailure) { await updateStatus(customerId, 'payment-failed') // Cleanup before throwing throw new NonRetryableError(`Payment permanently failed: ${result.reason}`) } return result })Never return error results instead of throwing — Returning { success: false, error } from a catch block is the same problem as top-level try/catch. The workflow engine sees a successful completion:
// 🔴 Wrong: Returning an error object swallows the failureexport class MyWorkflow extends WorkflowEntrypoint<Env, Params> { async run(event: WorkflowEvent<Params>, step: WorkflowStep) { try { const data = await step.do('fetch data', async () => fetchData()) return { success: true, data } } catch (error) { return { success: false, error: String(error) } // Workflow "succeeds" with an error payload } }}
// ✅ Correct: Let the error propagateexport class MyWorkflow extends WorkflowEntrypoint<Env, Params> { async run(event: WorkflowEvent<Params>, step: WorkflowStep) { const data = await step.do('fetch data', async () => fetchData()) return { success: true, data } // Unhandled errors propagate and fail the instance visibly }}Never nest step.do() inside another step.do() — Inner steps lose independent retryability. When the outer step’s result is cached on replay, the inner steps don’t re-execute:
// 🔴 Wrong: Nested steps aren't independently resumableawait step.do('process csv', async () => { const data = await readCsv()
// These inner steps are tied to the outer step's cache. // If the outer step succeeds, inner steps never run again on replay. await step.do('geocode addresses', async () => geocode(data)) await step.do('aggregate metrics', async () => aggregate(data))})
// ✅ Correct: Flat step structure -- each step independently resumableconst data = await step.do('read csv', async () => readCsv())await step.do('geocode addresses', async () => geocode(data))await step.do('aggregate metrics', async () => aggregate(data))Never poll with setTimeout inside a step — Not checkpoint-friendly. If the worker crashes mid-poll, the entire step restarts from the beginning. Use step.sleep() between polling steps, or step.waitForEvent() to avoid polling entirely:
// 🔴 Wrong: setTimeout polling loop inside a stepawait step.do('wait for child workflows', async () => { while (true) { const statuses = await checkStatuses(instanceIds) if (statuses.every((s) => s === 'complete')) return statuses await new Promise((r) => setTimeout(r, 30_000)) // Not checkpoint-friendly }})
// ✅ Correct: step.sleep() between polling stepslet complete = falsewhile (!complete) { complete = await step.do('check workflow statuses', async () => { const statuses = await checkStatuses(instanceIds) return statuses.every((s) => s === 'complete') }) if (!complete) { await step.sleep('wait before next poll', '30 seconds') }}
// ✅ Best: waitForEvent avoids polling entirelyawait step.do('trigger child workflow', async () => { return await env.CHILD_WORKFLOW.create({ id: childId, params })})const result = await step.waitForEvent('wait for child completion', { type: 'child-workflow-completed', timeout: '10 minutes',})Never process an unbounded collection in a single step — A step with no per-item resumability risks timing out and must restart all work from scratch:
// 🔴 Wrong: All items in one step -- no resumability, may timeoutawait step.do('process all workspaces', async () => { const workspaces = await getAllWorkspaces() await Promise.allSettled(workspaces.map((ws) => cleanupWorkspace(ws.id)))})
// ✅ Correct: One step per item -- independently resumableconst workspaces = await step.do('get workspace list', async () => getAllWorkspaces())for (const ws of workspaces) { await step.do(`cleanup workspace ${ws.id}`, async () => { return await cleanupWorkspace(ws.id) })}Conventions
Section titled “Conventions”Step Naming
Section titled “Step Naming”Use lowercase with spaces. Describe the action, not the implementation detail:
// ✅ Correctawait step.do('fetch campaign metrics', async () => {})await step.do('validate campaign for push', async () => {})await step.do('push targeting to google ads', async () => {})await step.do(`categorize image ${i + 1}`, async () => {})
// 🔴 Wrongawait step.do('fetch-campaign-metrics', async () => {}) // kebab-caseawait step.do('FetchCampaignMetrics', async () => {}) // PascalCaseawait step.do('call GADS API v18 endpoint', async () => {}) // implementation detailRetry Configuration
Section titled “Retry Configuration”Define named retry presets at the top of each workflow file rather than inlining config objects in every step.do(). Group presets by operation type:
import type { WorkflowStepConfig } from 'cloudflare:workers'
// Fast internal operations (DOs, KV, D1)const RETRY_QUICK: WorkflowStepConfig = { retries: { limit: 2, delay: '1 second', backoff: 'constant' }, timeout: '15 seconds',}
// Standard internal operationsconst RETRY_STANDARD: WorkflowStepConfig = { retries: { limit: 3, delay: '5 seconds', backoff: 'exponential' }, timeout: '1 minute',}
// External API calls (Google Ads, Meta, Twilio, etc.)const RETRY_EXTERNAL: WorkflowStepConfig = { retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' }, timeout: '2 minutes',}
// Slow external calls or AI generationconst RETRY_EXTERNAL_LONG: WorkflowStepConfig = { retries: { limit: 5, delay: '30 seconds', backoff: 'exponential' }, timeout: '10 minutes',}
// Use presets -- don't inlineawait step.do('update campaign record', RETRY_QUICK, async () => {})await step.do('call google ads api', RETRY_EXTERNAL, async () => {})await step.do('run ai generation', RETRY_EXTERNAL_LONG, async () => {})
// 🔴 Wrong: Inline retry objects scattered across stepsawait step.do( 'fetch data', { retries: { limit: 3, delay: '5 seconds', backoff: 'exponential' }, timeout: '1 minute' }, async () => {})Every step must have an explicit retry configuration. Do not rely on Cloudflare’s defaults silently.
Logging
Section titled “Logging”Use createLogger inside run() to get structured, tagged logs with workflow context. Log inside steps to avoid duplication on replay. Never use raw console.log:
import { createLogger } from '@/lib/logger'
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> { async run(event: WorkflowEvent<Params>, step: WorkflowStep) { const log = createLogger('MyWorkflow')
// ✅ Correct: Logging inside steps await step.do('process data', async () => { const result = await processData(event.payload.id) log.info`Processed ${result.count} items for ${event.payload.id}` return result })
// 🔴 Wrong: Logging outside steps duplicates on replay console.log(`[MyWorkflow] Starting for ${event.payload.id}`) log.info`Starting workflow` // Also duplicated on replay if outside a step }}Following these patterns ensures your Workflows are resilient, debuggable, and perform correctly across hibernation cycles and retries.