launch-quickly
Patterns

Background jobs

Defining, enqueueing and scheduling jobs — defineJob, the typed registry, cron triggers, step.run idempotency, and where withSystem is sanctioned. Use when work should not happen in the request, when adding a cron, or when a job retries strangely.

Generated from .claude/skills/background-jobs/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.

Work leaves the request path by being a job: declared with defineJob in src/lib/jobs/jobs/, registered in src/lib/jobs/registry.ts, enqueued with enqueue(id, input) — never inngest.send directly (lint enforces it).

Defining one

export const cleanupJob = defineJob({
  id: 'cleanup-pending-uploads',
  cron: '0 3 * * *', // optional; UTC. The event trigger stays either way,
  input: z.object({}), //  so a cron job can still be enqueued while debugging.
  handler: async ({ input, step, log }) => {
    const stale = await step.run('find-stale', () => …);
    await step.run('delete-rows', () => …);
    log.info('cleaned', { count: stale.length });
  },
});
  • Input is validated when the job RUNS, not only at enqueue — the payload crossed a process boundary as JSON. Cron invocations carry no payload, so a cron job's schema must parse {}.
  • step.run is the idempotency mechanism. On a retry, steps that already succeeded replay from their recorded result. Slice a job so each side effect is its own step, or a failure in step three re-runs step one.
  • Design handlers idempotent anyway — deletion by age, upsert by key, "already done" as a success. Retries are the normal case, not the edge.
  • Use the bound log, not the global logger: it carries the job id and run id, the job-world request id.

withSystem is sanctioned here — with its rules

A handler starts with no tenant, so src/lib/jobs/jobs/** is on the no-unscoped-db allowlist. That is a responsibility, not a convenience: scope every statement explicitly (by organization, by age, by key), because nothing else will. The audit-retention job is the model — it is deliberately the ONLY place in the codebase that deletes from audit_events, doing under the system role what the request path is structurally unable to do.

The shipped jobs are the patterns

  • send-email — fan-out through a queue so signup never waits on Resend.
  • cleanup-pending-uploads — nightly cron; bucket object deleted best-effort BEFORE the row, so bytes are never orphaned.
  • audit-retention — weekly cron; age-based deletion, idempotent by construction.

Local development: npx inngest-cli dev discovers the serve route and runs crons on schedule; without it jobs enqueue and simply wait.

On this page