Run background jobs
Queue runs for later or in the background: fan out to thousands of customers, import in pages, follow up days later, and retry what fails.
A run has 10 seconds. Most work fits; some doesn’t: emailing 3,000 people waiting for a product, importing 20,000 stock levels from an ERP, or asking for a review a week after delivery. Background jobs split that work into short runs. Any work can queue a job, now or later, and Littleworks delivers it at least once, retries it when it fails and shows what failed.
| Build | With jobs |
|---|---|
| Back-in-stock emails to 3,000 subscribers | The event work queues one job that emails 50 people a run and continues: about 60 runs |
| A nightly stock sync from an ERP that pages 500 records at a time | A schedule queues the first page; each page job continues with the next cursor |
| “Ask for a review 7 days after delivery” | The fulfillment work queues a job with a 7-day delay and the order as its key |
| Recalculating every product’s review summary | A job processes 250 products a run and continues with a cursor |
Job works
A job runs a published work whose trigger is {"type": "job"}. It runs at the work’s current version when the job runs, not when it was queued, like any other real run: same deadline, capabilities, logs and Runs entry. Its input is exactly what was queued, checked against the work’s inputSchema when it is queued and again when it runs. A job work never runs on its own; other works (events, schedules, Flow, pages, URLs, and job works themselves) queue it.
{
"name": "back_in_stock.send",
"feature": "back_in_stock",
"title": "Email one batch of back-in-stock signups",
"trigger": { "type": "job" },
"inputSchema": {
"type": "object",
"properties": { "productId": { "type": "string" } },
"required": ["productId"]
}
}context.trigger is "job", and context.job describes the job: {id, key, attempt, maxAttempts, previousRunIds, enqueuedAt, scheduledFor, enqueuedBy, rootId, generation}. rootId and generation place it in a chain of continuations.
Queue jobs
// Queue one job. Returns {id, status: "queued" | "duplicate", runAt}.
await jobs.enqueue("reviews.request", { orderId }, { delaySeconds: 7 * 86400, key: `review:${orderId}` });
// Queue up to 50 jobs of one work at once.
await jobs.enqueueMany("stock.notify", batches.map((b, i) => ({ input: b, key: `${event.id}:${i}` })));
// Queue this work again with new input: the next page of a long task.
await jobs.continue({ cursor: page.nextCursor });
// Cancel a queued (not running) job by key or id.
await jobs.cancel("reviews.request", { key: `review:${orderId}` });| Option | Meaning |
|---|---|
delaySeconds or at | When the job is due: now (the default) up to 30 days ahead. at is an ISO time. |
key | Up to 128 characters, unique per work while the job is kept (7 days after it finishes). Queuing a key again returns the existing job as duplicate and changes nothing. |
maxAttempts | 1 to 5 (default 5). |
- Kept only if the run completes. Jobs a run queues are held until the run finishes, whatever it returns. If the run throws, times out or is interrupted, they are discarded, and a retry of that run queues them again. A crashed run never leaves half a chain behind.
- Continue at most once.
jobs.continueisenqueueof the current work with the keycontinue:<job id>, so a job continues once even when its run is retried. Long work is a chain of short runs: do a page, then continue with a cursor. - Always key fan-out. Without a key, queuing twice queues two jobs. Key each batch by what it covers, such as the event ID and batch number, so a repeated event or retried run queues nothing twice.
// Event work: when a product is back in stock, queue the emails as a background job.
export default async ({ input, jobs }) => {
const productId = "gid://shopify/Product/" + input.payload.product_id;
// Keyed by the event: a repeated delivery or a retried run queues it once.
return jobs.enqueue("back_in_stock.send", { productId }, { key: `restock:${input.event.id}` });
};
// Job work: email the next 50 waiting signups, mark them, and continue until none are left.
export default async ({ input, db, http, secrets, jobs }) => {
const signups = db.collection("signups");
const page = await signups.list({ where: { productId: input.productId, status: "waiting" }, limit: 50 });
if (!page.items.length) return { done: true };
await http.fetch("https://api.email.example/batch", {
method: "POST",
headers: { Authorization: `Bearer ${await secrets.get("EMAIL_API_KEY")}` },
body: JSON.stringify({ to: page.items.map((s) => s.data.email), template: "back-in-stock" }),
});
// Mark them so the next step moves on to the next 50.
await db.batch(page.items.map((s) => ({ type: "update", collection: "signups", id: s.id, revision: s.revision, data: { ...s.data, status: "notified" } })));
await jobs.continue({ productId: input.productId });
return { emailed: page.items.length };
};// Job work: import one page of an ERP's stock levels, then continue with the next page.
export default async ({ input, http, secrets, shopify, jobs, context, log }) => {
const key = await secrets.get("ERP_API_KEY");
const page = await (await http.fetch(`https://erp.example.com/stock?cursor=${input.cursor ?? ""}`, {
headers: { Authorization: `Bearer ${key}` },
})).json();
// inventorySetQuantities takes up to 250 quantities in one call.
await shopify.admin.graphql(SET_QUANTITIES, { input: toQuantities(page.items) });
if (page.nextCursor) await jobs.continue({ cursor: page.nextCursor });
log.info("Page", context.job.generation + 1, "attempt", context.job.attempt);
return { imported: page.items.length, more: Boolean(page.nextCursor) };
};How delivery works
- At least once. In rare cases, such as an instance stopping after Shopify accepted a change but before the run was recorded, a job’s attempt can start again. Use
context.job.attempt,previousRunIdsand your own records (anemailedAtfield, a processed cursor) so repeating it does no harm. - Retries. A run that throws, times out or exceeds a resource limit is retried after 1 minute, 10 minutes, 1 hour and 6 hours, up to
maxAttempts. A returned 4xx or 5xx response counts as handled and isn’t retried; throw to ask for a retry. - Waiting doesn’t use attempts. A busy store waits a minute. When the run allowance is used up, jobs wait up to 24 hours for it, then expire with a notice. Jobs for a paused work wait up to 7 days and run when it is resumed; the work’s page can cancel them instead. Deleting the work or uninstalling the app cancels them.
- Failures stay visible. A job that fails its last attempt is kept for 7 days with its error and every run, with a notice on the work. Retry it after a fix with
retry_jobor the Retry button on the work’s page; it gets a fresh set of attempts. - Pace. Each store runs at most 2 jobs at a time, in their own lane: an import never delays Shopify events (up to 6 at a time) or storefront traffic, and one store’s queue never delays another store’s. Due jobs run oldest first, without a strict order; a chain of continuations runs one step at a time.
- Shopify throttling. A job that hits Shopify’s rate limit (
SHOPIFY_THROTTLED) is retried a minute later. To go faster, readextensions.cost.throttleStatusfrom the response and continue with a short delay before the bucket runs dry.
Runs and pricing
Every job attempt that starts a run counts as one run against the store’s allowance, like any other run. Queuing, de-duplicating, cancelling and waiting are free. Jobs usually make big work cheaper in runs, because each run does up to ten seconds of work: 3,000 back-in-stock emails, sent 50 to an API request, take about 60 runs. Usage shows job runs as Background jobs, and the notice for one work taking most of a day’s runs covers runaway chains.
Test and watch
- Test a job work directly.
invoke_functionwith input andtrigger: {job: {attempt?, previousRunIds?, key?}}fillscontext.jobas a queued job would. From the CLI:littleworks invoke back_in_stock.send '{"productId":"gid://shopify/Product/1"}' --job. - Jobs from test runs are test jobs. A test run’s jobs run the target’s newest version as test runs, at most 200 a day per store, due within 1 hour, and a chain stops after 20 steps. Like every test run, they use the store’s real Shopify data and call real external services, so test a fan-out with a small list first.
- Watch.
list_jobs(CLI:littleworks jobs) lists jobs with their status, attempts, runs and error; inputs are never returned, only their size.cancel_jobs(littleworks jobs cancel <work>) cancels queued ones andretry_job(littleworks jobs retry <id>) re-queues a failed one.get_functionshows a job work’s queued, running, succeeded and failed jobs. In Shopify admin, Works filters by Runs from: Background jobs, and a job work’s page shows its jobs and failed jobs with Retry and Cancel. - Locally. With
npm run dev:local, queued jobs run from the local maintenance loop.npm run local:tick -- --at <time>runs the jobs due by then, so delayed jobs and retries can be tried without waiting.
Limits
| Limit | Value |
|---|---|
| Job input | 64 KiB; keep larger data in storage and pass its ID |
| Delay | Up to 30 days |
| Jobs queued per run | 100; enqueueMany takes up to 50 at once |
| Waiting jobs per store | 5,000, and 20 MB of their input; beyond that, JOB_QUEUE_FULL |
| Chain length | 10,000 steps from one job; the next continuation fails with JOB_CHAIN_LIMIT and a notice |
| Attempts | 1 to 5, retried after 1 minute, 10 minutes, 1 hour and 6 hours |
| Job runs at a time | 2 per store |
| Kept after finishing | 7 days (succeeded, failed, cancelled and expired) |
Jobs reach only the store’s own job works, and only a running work can queue them, so storefront visitors can’t queue jobs directly. A public URL work that queues a job per request should key it, so repeated requests don’t multiply. For bulk Shopify writes, prefer mutations that take many items at once, such as inventorySetQuantities (250 quantities), productVariantsBulkUpdate (a product’s variants) and metafieldsSet (25 metafields), over one mutation per record.