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

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 replay
console.log('Workflow started') // Logged multiple times on restart
const badInstance = await this.env.ANOTHER_WORKFLOW.create() // Creates duplicate instances
const badTimestamp = new Date().toISOString() // Different value on each replay
// 🔴 Wrong: Non-deterministic values outside steps cause divergent paths
const 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 steps
const 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 lifetime
const db = createDBConnection(this.env.DB_URL, this.env.DB_TOKEN)
// ✅ OK: Deterministic validation that throws NonRetryableError
const parsed = mySchema.safeParse(event.payload)
if (!parsed.success) {
throw new NonRetryableError(`Invalid params: ${parsed.error.message}`)
}

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 step
if (Math.random() > 0.5) {
await step.do('maybe run', async () => {
/* ... */
}) // Different result on replay
}
// ✅ Correct: Wrap non-deterministic values in a step first
const shouldProcess = await step.do('decide randomly', async () => Math.random() > 0.5)
if (shouldProcess) {
await step.do('conditionally process', async () => {
/* ... */
})
}

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
}

Step timeouts must be 30 minutes or less. For longer waits, use step.waitForEvent():

// ✅ Correct: Timeout within the 30-minute limit
await step.do('call external api', { timeout: '5 minutes' }, async () => {
return await fetch('/api/long-running-task')
})
// 🔴 Wrong: Timeout exceeds 30 minutes
await step.do(
'wait for processing',
{ timeout: '2 hours' }, // Will not work as expected
async () => {
return await pollUntilComplete()
}
)
// ✅ Correct: Use waitForEvent for long waits
await 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
})

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 MiB
const 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 reference
const 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 needed
await step.do('process dataset', async () => {
const stored = await this.env.BUCKET.get(dataRef.key)
return processData(await stored!.json())
})

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 step
const startedAt = await step.do('capture start timestamp', async () => {
return new Date().toISOString()
})
// ✅ Correct: UUID generated inside a step
const generationId = await step.do('generate unique id', async () => {
return crypto.randomUUID()
})
// Then use these deterministic values in subsequent steps
await step.do('create record', async () => {
return await createRecord({ id: generationId, startedAt })
})
// 🔴 Wrong: Non-deterministic values outside steps
const startedAt = new Date().toISOString() // Different on every replay
const id = crypto.randomUUID() // New UUID generated on every replay

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 resumable
const 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 limits
const BATCH_SIZE = 10
for (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 timeout
await step.do('process all images', async () => {
return await Promise.all(images.map((img) => categorizeWithAI(img)))
})

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-effort
const 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 workflow
await 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
})

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 cleanup
const 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 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 fine
const rawMetrics = await step.do('fetch metrics', async () => {
return await fetchMetricsFromPlatform(campaignId, dateRange)
})
// Pure computation -- deterministic, no side effects, safe outside step.do
const processedMetrics = processMetricsSyncData(rawMetrics)
const dailyBreakdown = aggregateDailyMetrics(processedMetrics)
const weeklyRollup = aggregateDailyToWeekly(dailyBreakdown)
// Only I/O goes inside steps
await step.do('persist processed metrics', async () => {
return await saveMetrics(campaignId, weeklyRollup)
})

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 () => {
/* ... */
})

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 engine
export 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 failed
export 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 failure
export 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 propagate
export 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 resumable
await 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 resumable
const 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 step
await 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 steps
let complete = false
while (!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 entirely
await 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 timeout
await 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 resumable
const 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)
})
}

Use lowercase with spaces. Describe the action, not the implementation detail:

// ✅ Correct
await 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 () => {})
// 🔴 Wrong
await step.do('fetch-campaign-metrics', async () => {}) // kebab-case
await step.do('FetchCampaignMetrics', async () => {}) // PascalCase
await step.do('call GADS API v18 endpoint', async () => {}) // implementation detail

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 operations
const 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 generation
const RETRY_EXTERNAL_LONG: WorkflowStepConfig = {
retries: { limit: 5, delay: '30 seconds', backoff: 'exponential' },
timeout: '10 minutes',
}
// Use presets -- don't inline
await 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 steps
await 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.

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.