Steps & Workflow Design What steps are, when to use them, fan-out patterns, and best practices for reliable workflows. Steps are how JsWorkflows breaks a workflow into reliable, independent pieces of work. Designing your workflow around clear steps is one of the main differences between a workflow that is easy to maintain and recover, and one that becomes hard to retry, extend, or reason about. What a step is A step is a single method on your Workflow class. Each time a step runs, it is a fresh, independent execution with: - its own execution context - no shared in-memory state between steps - full access to the platform API (api.scheduleNextStep, api.runStore, and more) - its own data, headers, and api arguments When api.scheduleNextStep() is called, the current step exits normally. After the specified delay, the next step is dispatched as a separate execution, not a continuation of the same process. Why steps exist Real workflows often need to: - Wait — send a follow-up message later, check a status after a delay, or resume after an external event - Spread load — process many records without forcing everything into one execution - Run in parallel — let independent work happen in separate branches - Separate orchestration from work — validate and queue in start, then do the delayed, retryable, or external work in later steps All of these are solved by splitting the work across steps. How start should be used start is the reserved entry point for every workflow run. In many workflows, start is best used for: - validating the incoming payload - preparing the payload for later steps - scheduling the next step - deciding which branch or path should run next That is usually the right design because start cannot be scheduled again with api.scheduleNextStep(). If work may need to be delayed, retried, or broken into smaller units, it should usually live in later named steps. Trigger-specific behavior start behaves a little differently depending on the trigger type: - Shopify webhook triggers: JsWorkflows acknowledges the webhook before your code runs. Your start step is not responsible for sending the HTTP response back to Shopify. - HTTP triggers: start can return the HTTP response. If it returns a Response, that response is sent directly. If it returns a plain object or value, JsWorkflows returns it as JSON. If it returns nothing, the platform returns { "success": true }. - Email triggers: start receives the parsed email as data (sender, subject, body, and attachment descriptors). Attachments are not passed directly; use api.getAttachment() in a later retryable step to read the file content. - Scheduled triggers: start is simply the entry point for the scheduled run. A good start step async start(data, headers, api) { if (!data.id) return; await api.scheduleNextStep({ delay: '30 sec', action: 'processOrder', payload: { orderId: data.id }, }); } Linear chains The simplest multi-step pattern is a linear chain: start → stepA → stepB → done. export class Workflow { async start(data, headers, api) { await api.scheduleNextStep({ delay: '1 day', action: 'sendFollowUp', payload: { orderId: data.id, email: data.email }, }); } async sendFollowUp({ orderId, email }, headers, api) { await api.scheduleNextStep({ delay: '7 days', action: 'sendSecondFollowUp', payload: { orderId, email }, }); } async sendSecondFollowUp({ orderId, email }, headers, api) { // Runs later as a separate execution } } Each step only needs to know about the next one. Steps do not share memory, so pass everything the next step needs in payload. Fan-out (parallel branches) Call api.scheduleNextStep() multiple times in one step to launch parallel branches. Each call starts an independent execution of the target method. The run is not marked complete until all branches finish. export class Workflow { async start(data, headers, api) { for (const item of data.lineitems) { await api.scheduleNextStep({ delay: '30 sec', action: 'checkLineItem', payload: { itemId: item.id, sku: item.sku, quantity: item.quantity }, }); } } async checkLineItem({ itemId, sku, quantity }, headers, api) { console.log(Processing item ${sku} ×${quantity}); } } Fan-out is useful when each item can be processed independently. Collecting results from fan-out branches Use api.runStore.push() to accumulate results from parallel branches, then read them later in a consolidation step or lifecycle hook: export class Workflow { async start(data, headers, api) { for (const item of data.lineitems) { await api.scheduleNextStep({ delay: '30 sec', action: 'checkLineItemStock', payload: { itemId: item.id, sku: item.sku }, }); } } async checkLineItemStock({ itemId, sku }, headers, api) { const res = await fetch(https://your-inventory-api.com/stock/${sku}); const { quantity } = await res.json(); await api.runStore.push('stockResults', { sku, quantity, inStock: quantity > 0 }); } } The run state (api.runStore) is scoped to the current run and shared across all branches. A later step or lifecycle hook can read stockResults and use it to build a summary, trigger a follow-up action, or write a report. Using steps to control large workloads If your workflow processes large lists, doing all the work in one step can create throttling, retries, or hard-to-debug failures. Steps let you spread that work across time or across parallel branches. Spread work across staggered steps async start(data, headers, api) { const items = data.lineitems; for (let i = 0; i < items.length; i++) { await api.scheduleNextStep({ delay: ${30 + i} sec, action: 'updateItem', payload: { itemId: items[i].id }, }); } } Page through large result sets across steps This is a more advanced example. Focus on the step pattern: fetch one page, process it, then schedule the next page only if more data remains. async start(data, headers, api) { await api.scheduleNextStep({ delay: '30 sec', action: 'fetchPage', payload: { cursor: null, pageNum: 1 }, }); } async fetchPage({ cursor, pageNum }, headers, api) { const res = await fetch( https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPIVERSION}/graphql.json, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: query GetProducts($cursor: String) { products(first: 50, after: $cursor) { nodes { id title } pageInfo { hasNextPage endCursor } } }, variables: { cursor }, }), } ); const { data: gqlData } = await res.json(); const { nodes, pageInfo } = gqlData.products; // process this page if (pageInfo.hasNextPage) { await api.scheduleNextStep({ delay: '30 sec', action: 'fetchPage', payload: { cursor: pageInfo.endCursor, pageNum: pageNum + 1 }, }); } } Idempotency Design every step so it is safe to retry. Running the same step twice with the same input should not create a worse outcome than running it once. Practical strategies: - Use api.dedupe() when your workflow needs business-level deduplication — for example, to prevent sending the same reminder twice for the same order or to stop an HTTP-triggered workflow from re-processing the same external event - Check before writing — before tagging an order or sending a message, check whether it already happened - Use external idempotency keys — many APIs support this; use a stable event or business ID For Shopify webhook triggers, duplicate delivery protection is already handled by the platform before your workflow code runs. api.dedupe() is still useful when you want your own workflow-specific deduplication rules. Payload design The payload you pass to api.scheduleNextStep() is persisted until the later step runs. Keep it lean: - Pass only what the next step needs - Avoid large blobs when possible - Do not depend on in-memory state If a later step needs something, pass it in payload or store it in api.runStore. When not to use a step Not every workflow needs multiple steps. A single start step can be enough when: - the work completes very quickly - you are using an HTTP trigger and want to return the response directly from start - you are in a scheduled trigger and the job is short and straightforward The overhead of scheduling another step is unnecessary for work that can safely run in start.