Steps & Workflow Design
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
Section titled “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, andapiarguments
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
Section titled “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
Section titled “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
Section titled “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
startstep is not responsible for sending the HTTP response back to Shopify. - HTTP triggers:
startcan return the HTTP response. If it returns aResponse, 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:
startreceives the parsed email asdata(sender, subject, body, and attachment descriptors). Attachments are not passed directly; useapi.getAttachment()in a later retryable step to read the file content. - Scheduled triggers:
startis simply the entry point for the scheduled run.
A good start step
Section titled “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
Section titled “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)
Section titled “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.line_items) { 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
Section titled “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.line_items) { 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
Section titled “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
Section titled “Spread work across staggered steps”async start(data, headers, api) { const items = data.line_items; 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
Section titled “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.SHOPIFY_STORE}/admin/api/${env.SHOPIFY_API_VERSION}/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
Section titled “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
Section titled “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
Section titled “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.