# 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.

Source: https://littleworks.app/docs/background-jobs

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.

### Manifest

```json
{
  "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

### The jobs SDK

```javascript
// 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.continue` is `enqueue` of the current work with the key `continue:<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.

### Fan out from an event (two works)

```javascript
// 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 };
};
```

### Import in pages

```javascript
// 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`, `previousRunIds` and your own records (an `emailedAt` field, 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_job` or 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, read `extensions.cost.throttleStatus` from 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_function` with input and `trigger: {job: {attempt?, previousRunIds?, key?}}` fills `context.job` as 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 and `retry_job` (`littleworks jobs retry <id>`) re-queues a failed one. `get_function` shows 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.
