Background jobs and schedules
Apps/Build/Background jobs and schedules

Background jobs and schedules

Run an action later, retried when it fails: queued from the app's code, by a Shopify webhook, or on a schedule.

Queueing an action

An action that calls Shopify for thousands of items, or a third-party API that's sometimes down, shouldn't run inside the request that asked for it. api.enqueue queues it as a background job and returns at once with the job. The action is any global action by name, or a model's as model.action with id for its record.

import type { GlobalActionRun } from "@inferencebureau/apps-sdk";

/** Starts a catalog sync per store and returns at once; each store's sync runs as its own job. */
export const run: GlobalActionRun = async ({ api }) => {
  const shops = await api.shopifyShop.findMany({ filter: { state: { equals: "installed" } } });
  for (const shop of shops.records) {
    await api.enqueue("syncCatalog", { since: "2026-10-01" }, {
      shop: String(shop.myshopifyDomain),
      concurrencyKey: String(shop.myshopifyDomain), // one sync per store at a time
      retries: 3,
    });
  }
};

For a job per record, api.enqueueMany(action, [{ id, params }], options) queues up to 250 in one call with the options shared, checking every one before any is queued, and returns the jobs in order. For one job over a whole batch, pass the batch as a global action's params.

A job runs the action the way a call does: the same context, shopify client for shop, logs, and writes made in one transaction. It gets five minutes for run and onSuccess together when the file sets no timeoutMS, and up to 15 minutes when it does.

import type { ActionOptions, GlobalActionRun } from "@inferencebureau/apps-sdk";

export const run: GlobalActionRun = async ({ params, shopify, logger }) => {
  // Up to 15 minutes, run and onSuccess together.
};

export const options: ActionOptions = { timeoutMS: 900_000 };

Options

OptionWhat it doesDefault
idThe record a model's action works on.—
shopThe store the action's shopify client talks to.—
delay, runAtMilliseconds to wait, or a time to run at. At most 30 days ahead.Now
retriesTries after the first, when it fails. Up to 20.6
priorityhigh jobs run before default before low.default
concurrencyKey, concurrencyAt most this many of the environment's jobs with the key run at once. Use the store's domain to run one job per store at a time.No limit · 1 with a key

Long-running work

Fifteen minutes is the most one attempt gets. Work that can take longer, like going through a whole catalog or order history, has to be split. Process one page, then queue the next page as its own job with its cursor in params. Smaller attempts are better even when one would fit:

  • A retry starts its attempt over, so a failure late in a long attempt repeats all of it.
  • If the runner stops mid-attempt (a deploy of the platform, a crash), the job is tried again only when its lease runs out, 16 minutes after the attempt started.
  • A concurrencyKey holds its slot for the whole attempt, so long attempts hold up the jobs queued behind them.
import type { GlobalActionRun } from "@inferencebureau/apps-sdk";
import { reprice } from "../lib/pricing";

/** One page of products per attempt; the next page is its own job, so a failure retries one page, not the catalog. */
export const run: GlobalActionRun = async ({ params, api, shop, signal, logger }) => {
  const page = await api.shopifyProduct.findMany({ first: 250, after: params.cursor as string | undefined });
  for (const product of page.records) {
    if (signal.aborted) return; // canceled from the Queue
    await reprice(product);
  }
  logger.info("Repriced a page", { count: page.records.length });
  if (page.hasNextPage) await api.enqueue("repriceCatalog", { cursor: page.endCursor }, { shop: shop!, concurrencyKey: shop! });
};

Pass the action's signal to fetch, and check signal.aborted between items, so a cancel or a timeout stops the work, not just the wait. Records the attempt saved with save are written only if it finishes; writes through api.<model>.create, update or delete and calls to shopify have happened already, so a retried page must cope with them (see below).

Retries and failures

A job fails when its action throws, its write is refused, it runs out of time, or the app doesn't answer (development's dev server is started first when it's asleep). It's tried again after 15 seconds, then 30, doubling up to an hour. Out of tries, it's kept as failed with the last error. A job for an action that no longer exists fails without retrying.

An attempt can run again if the runner stops mid-run, so write actions that can safely run twice: check whether the work is already done before doing it.

Canceling a job

A queued job is canceled at once. A running one shows as canceling until its runner stops it, within a few seconds: the call to the app is aborted, the action's signal fires, and the job ends canceled with no more tries. Whatever run returns after that is dropped, so the record isn't written. Writes it already made through api or shopify stand, and so does a run that finished before the cancel reached it. Pass signal to fetch, or check signal.aborted between steps of a long loop, to stop the work too.

Shopify webhooks

Any Shopify webhook topic can run your code. List it under webhooks in settings.ib.ts with the global action it queues; the action gets Shopify's payload as params and the store as shop. filter (Shopify's search syntax) and includeFields narrow what Shopify sends. Each entry is its own subscription, so a filter never narrows what synced models receive.

import type { AppSettings } from "@inferencebureau/apps-sdk";

export const settings: AppSettings = {
  type: "ib/settings/v1",
  platform: "shopify",
  webhooks: [
    { topic: "orders/create", action: "orderCreated" },
    { topic: "inventory_levels/update", action: "restock", filter: "available:<5", includeFields: ["inventory_item_id", "available"] },
    { topic: "app_subscriptions/update", action: "planChanged" },
  ],
  schedules: [
    { action: "nightlyDigest", cron: "0 3 * * *", timezone: "America/New_York", catchUp: true },
    { action: "cleanup", cron: "*/30 * * * *", environments: ["development", "production"] },
  ],
};

Webhooks go out with the Shopify app's configuration: saving settings.ib.ts releases development's, and a deploy releases production's. The app's scopes must allow the topic (read_orders for orders/create), or Shopify refuses the release and Problems says why. Shopify gets its answer as soon as the job is queued; a redelivery of the same webhook is recognized and queues nothing.

Deliveries for a store that hasn't installed the app (it never did, or it left) are answered, logged as orphaned subscriptions (error = orphaned_subscription in Logs), and not handled or billed. The privacy topics run on their own; see Shopify apps.

Schedules

Under schedules, each entry queues a global action on a cron: five fields (minute, hour, day of month, month, day of week) with *, ranges, lists, steps and names (0 9 * * MON-FRI). timezone is an IANA name, UTC when left out. When both day fields are set, either one matching is enough, as in standard cron. The action gets params.scheduledAt, the time it was due.

Schedules run in production only, unless environments names development too. Production's schedules are its last deploy's. Each time is queued once. A run missed by a few minutes is caught up; one missed by longer is skipped, not replayed, unless the entry sets catchUp: true. Then the times it missed in the last day (an outage, or while the team's apps were paused) are queued when it's back, the latest 24 of them, each with its own scheduledAt. Times before settings.ib.ts last changed are never owed. Schedules wait while the team's apps are paused.

The Queue

The app's Queue page lists each environment's jobs in three groups: Needs attention (failed, or trying again after an error), Up next (running, then waiting by priority and run time) and Finished (the last 7 days). Queued and running jobs show whatever their age. Filter by state or search by action, shop or job id. Each row shows what queued it, its last error, and a square per try. Picking a job opens its attempts (each linked to its run in Logs), its details and its params beside the list, under a chart of the last 24 hours' finished jobs. Admins get Cancel job for a queued or running job, Run now for a queued one, Retry for a failed or canceled one, and Retry N failed for every failed job at once.

Below, What queues jobs here shows a card for the schedules, with each one's next three times, and for a Shopify app one for the webhooks that queue actions, with each topic's filter, fields and deliveries in the last 24 hours. Each attempt is an action run in Logs (the Background jobs view, source = job), and a webhook's job sits under its delivery. Through the API, GET …/jobs lists the queue (?state= to filter: queued, running, canceling, succeeded, failed or canceled), GET …/jobs/:id reads one job, and POST …/jobs/:id/cancel and …/retry do the same as the buttons.

Billing

Each attempt is a run, shown as jobs on the Usage page. A job a Shopify webhook queued is part of that webhook's run, not one of its own. Orphaned deliveries aren't billed.