Skip to main content
Subscribers and scheduled jobs are the two ways work happens outside a request. Both follow the same core rule as everything else: they never mutate directly, they run a workflow.

Subscribers: react to events

A subscriber listens for a domain event (emitted by a workflow via emitEventStep) and runs an asynchronous side effect, such as sending a notification, syncing a search index, or creating a link. It lives in src/subscribers/ and exports a handler plus a config naming the event.
apps/api/src/subscribers/brand-created.ts

Fetch full data from { id }

Event payloads carry ids, not entities. A subscriber receives { id } (sometimes a couple of ids) and must fetch the full record it needs via Query. Never rely on a fat event payload. It goes stale and couples the emitter to every consumer’s needs.
Fetch what you need through Query, keyed by the id on the event.
apps/api/src/subscribers/brand-created.ts

Mutate via workflows, never directly

If the subscriber needs to change data, it runs a workflow, same as a route would. The subscriber is the trigger. The workflow is the work.
apps/api/src/subscribers/brand-created.ts

Log, don’t throw

A subscriber runs detached from the request. Throwing does not surface to a user. It just fails silently or spams retries. Catch errors and log them (resolve the logger), then decide explicitly whether to rethrow for a retry or swallow.
apps/api/src/subscribers/brand-created.ts

Idempotency and loop guards

Events can be delivered more than once, and a subscriber that mutates data can re-trigger the very event it listens to. You have two defences:
  • Idempotency: make the handler safe to run twice. Check current state before acting (for example, “is this product already linked to a brand?” before creating the link), or clear the marker that triggered the work so a redelivered event finds nothing left to do.
  • Loop guards: if handling event X causes a mutation that emits X again, gate on a condition that becomes false after the first run, or key off a marker you set. Never emit the same event unconditionally from its own subscriber.
A good idempotency check reads the current state through Query first and returns early if the work is already done. This makes redelivery harmless and removes the need for exactly-once guarantees.

Scheduled jobs: periodic work

A scheduled job runs on a cron interval to do time-based work, such as polling for records that became ready, reconciling drifted counters, or emitting a “settle now” event. It lives in src/jobs/, exports a handler taking the container, and a config with a name and a cron schedule.
apps/api/src/jobs/deactivate-stale-brands.ts

When to use a job vs a subscriber

Pick the trigger that matches how the work starts. A time-based pipeline often combines both: a daily job finds records that became eligible and emits an event (say brand.review_due), and a subscriber turns each event into a workflow run. Polling for “what’s ready” is the job. Reacting to each item is the subscriber.

Job best practices

  • Batch and bound result sets: a job that SELECTs an unbounded table will eventually time out. Page through with LIMIT/OFFSET or a cursor.
  • Idempotent by design: a job re-runs on every tick, so it must only act on records still needing action (filter on the not-yet-processed state).
  • Mutations run workflows: reads run Query, same as everywhere.
  • Log a summary: report how many records processed each run so drift is visible.

Checklist

  • Subscriber config.event names a real emitted event; handler fetches full data from { id } via Query.
  • Subscriber mutations run a workflow; errors are caught and logged, not thrown blindly.
  • Handler is idempotent and can’t retrigger its own event without a guard.
  • Job exports { name, schedule }; cron is correct (UTC).
  • Job filters to records still needing work, batches large sets, and logs a summary.
  • Neither a subscriber nor a job writes to the database outside a workflow.

Next steps

Workflows

Do all mutations through a workflow so subscribers and jobs stay thin triggers.