bunderstackbunderstack/ docs

Background jobs and cron

One table, one loop, one deployable process

Everything in the background is a row in one table. A job is a type, a payload, a time to run, and an attempt count. A cron is a job that gets created on a schedule. One loop — tick() — moves rows forward.

import * as v from 'valibot'

const backend = bunderstack({
  schema,
  jobs: (j) =>
    j.define({
      sendEmail: j.job({
        input: v.object({ userId: v.string() }),
        retries: 3,
        handler: async ({ userId }, ctx) => {
          // ctx.db, ctx.email, ctx.storage, ctx.jobs, ctx.realtime, ctx.env
        },
      }),
      dailyReport: j.cron({
        schedule: '0 9 * * 1-5',
        retries: 2,
        handler: async ({ scheduledFor }, ctx) => {},
      }),
    }),
})

const app = await backend.start()

await app.jobs.enqueue('sendEmail', { userId: 'usr_123' })

j.job() is durable queue work and is the only declaration accepted by app.jobs.enqueue(). Delivery is at-least-once: make handlers idempotent.

Production worker

The default all role keeps local development and small single-process deployments simple. In production, run queue work as a separate process and keep the web runtime from auto-starting another loop:

// src/worker.ts
import { backend } from './bunderstack/backend'

const app = await backend.start({
  env: { ...process.env, BUNDERSTACK_ROLE: 'web' },
})
await app.runWorker()

Topology is controlled by BUNDERSTACK_ROLE, not by application code:

BUNDERSTACK_ROLEServes HTTPRuns background work
all (default)yesyes
webyesno
workernoyes

Use BUNDERSTACK_ROLE=web for the web entry. The dedicated worker command owns runWorker() and its shutdown lifecycle.

# One process, everything. The default.
bun run start

# Split production commands.
BUNDERSTACK_ROLE=web bun run start
bun run worker

app.backgroundRunning reports whether the current process runs the loop. app.startWorker() is useful for an explicitly embedded worker; app.runWorker() owns a dedicated worker process until shutdown.

Concurrency

Queue jobs run in a continuous per-type pool. With one worker, omitted concurrency gives that type 10 execution slots.

generateAnswer: j.job({
  concurrency: 32,
  timeout: 120_000,
  handler: async (input, ctx) => {},
})

The worker claims rows in internal batches of at most 10 until all 32 slots are full. Ten is a database batch size, not a concurrency ceiling. When one handler finishes, the worker fills that slot immediately without waiting for the other handlers that started beside it.

Across several worker processes, the current database-observed capacity check is best effort and can race; concurrency is not a strict provider-wide semaphore. Use an application/provider limiter when several workers share one external quota.

Cron

j.cron() uses a five-field UTC schedule. Each due minute is materialized as a job row whose dedupe key is the slot timestamp, so a slot runs exactly once no matter how many processes are ticking — including the brief overlap during a rolling deploy.

Because cron occurrences are jobs, they take the same options queue jobs do:

weeklyDigest: j.cron({
  schedule: '0 9 * * 1',
  retries: 3, // a throwing handler now retries with backoff
  timeout: 120_000, // lease duration
  catchUp: 'latest', // or 'all'
  onFailed: async (invocation, error, ctx) => {},
  handler: async ({ scheduledFor }, ctx) => {},
})

Missed slots

If the process was down when a slot came due, catchUp decides what happens on the next tick:

  • 'latest' (default) — only the most recent missed slot runs. Right for handlers that bring state up to date.
  • 'all' — every missed slot runs, bounded by catchUpWindow (default one hour). Right when each interval represents distinct work.

A newly declared cron never backfills from the past — it starts from the minute it is first seen.

Job names may not begin with cron:; the prefix is reserved.

Testing jobs deterministically

Fixtures never auto-start background work. fixture.jobs.runNext() claims and runs one tick, while fixture.jobs.runUntilIdle() runs ticks until all runnable jobs are processed:

await using fixture = await backend.test()

await fixture.app.jobs.enqueue('sendWelcomeEmail', { userId: 'user_1' })

const report = await fixture.jobs.runUntilIdle({ failOnJobError: true })
expect(report.ran).toBe(1)

expect(await fixture.jobs.pending({ name: 'sendWelcomeEmail' })).toEqual([])
expect(await fixture.jobs.failed()).toEqual([])

Jobs recursively enqueued by handlers are processed until the queue converges or maxTicks is reached. Delayed jobs wait for their explicit now timestamp without sleeping.

Use fixture.jobs.inspect(filter?) for every retained queue row, or pending(filter?) and failed(filter?) for status-specific assertions. Filters accept name and dedupeKey; rows expose id, normalized name, kind, status, attempts, runAt, dedupeKey, and lastError.

Realtime from a separate worker process

With BUNDERSTACK_ROLE=all — the default — job handlers and realtime subscribers share a process, so realtime works with no extra configuration.

When you split roles, the web and worker processes are separate. An in-memory realtime broker cannot carry an event published by a job handler to clients connected to the web process. If a handler calls ctx.realtime.publish(), configure a shared Redis transport in both:

const backend = bunderstack({
  schema,
  realtime: { redis: process.env.REDIS_URL! },
  jobs: (j) =>
    j.define({
      generateImage: j.job({
        input: v.object({ itemId: v.string() }),
        handler: async ({ itemId }, ctx) => {
          const item = await generateAndSaveItem(itemId)
          await ctx.realtime.publish(schema.items, 'update', item)
        },
      }),
    }),
})

const app = await backend.start()
REDIS_URL=redis://localhost:6379 BUNDERSTACK_ROLE=web bun run start
REDIS_URL=redis://localhost:6379 BUNDERSTACK_ROLE=worker bun run start

Setting REDIS_URL is enough when realtime is enabled; the explicit realtime.redis option is useful when the URL comes from another source. realtime: true without Redis selects a process-local memory transport, which is correct for BUNDERSTACK_ROLE=all.

app.runWorker() rejects the unsafe standalone memory-transport combination rather than silently losing cross-process events. If handlers never publish realtime events, say so explicitly:

await app.runWorker({ allowProcessLocalRealtime: true })

Inspect the selected transport through app.realtime.transport ('disabled', 'memory', or 'redis'). The deploy manifest exposes the configured value as app.manifest.realtimeTransport.

Standalone job handlers

When extracting handler logic into separate files, annotate ctx with BunderstackJobContext:

// src/server/jobs/generate-resume.ts
import type { BunderstackJobContext } from 'bunderstack'

export async function generateResume(
  adaptationId: string,
  ctx: BunderstackJobContext,
) {
  const url = await ctx.storage.getUrl(`adaptations/${adaptationId}/resume.pdf`)
  await ctx.email.send({ ... })
}

Retries and failures

A handler that throws is retried with jittered exponential backoff until retries is exhausted, then the row is marked failed with its error and onFailed fires once. Failed rows are never reaped, so they remain queryable in _bunderstack_jobs. Succeeded rows are removed after 24 hours.

Long cron work should enqueue a queue job and return quickly rather than holding its lease for minutes.

On this page