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_ROLE | Serves HTTP | Runs background work |
|---|---|---|
all (default) | yes | yes |
web | yes | no |
worker | no | yes |
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 workerapp.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 bycatchUpWindow(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 startSetting 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.