Skip to content

Background jobs

This guide runs scheduled work inside the server’s process — a sweep every few minutes, a cache refreshed every few seconds — and stops it with the server through @tetsujs/lifecycle. There is no jobs package: Bun.cron and setInterval do the scheduling, and the rest is a few lines you own.

This job deletes expired sessions every five minutes. On shutdown, the pool is closed only after a run in progress ends:

import { onShutdownSignals } from "@tetsujs/lifecycle";
const
const reportError: ReportError
reportError
: ReportError = ({
source: FailureSource
source
,
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
}) =>
const logger: Logger
logger
.
error: pino.LogFn
<{
err: unknown;
source: FailureSource;
}, "failed">(obj: {
err: unknown;
source: FailureSource;
} & pino.LogFnFields, msg?: "failed" | undefined) => void (+2 overloads)
error
({
err: unknown
err
:
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
,
source: FailureSource
source
}, "failed");
async function sweepExpired(
signal: AbortSignal
signal
: AbortSignal): Promise<void> {
while (!
signal: AbortSignal
signal
.
aborted: boolean

The aborted read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (true) or not (false).

MDN Reference

aborted
) {
const
const deleted: number
deleted
= await
const sessions: {
deleteExpired(options: {
limit: number;
}): Promise<number>;
}
sessions
.deleteExpired({
limit: number
limit
: 500 });
if (
const deleted: number
deleted
< 500) return;
}
}
const
const server: Bun.Server<unknown>
server
= Bun.serve({ ...createApp({ reportError, routes }),
port?: string | number | undefined

The port the server listens on

@default ― process.env.PORT || "3000"

port
: 3000 });
let
let running: Promise<void> | undefined
running
: Promise<void> | undefined;
const
const shutdown: ShutdownHandle
shutdown
= onShutdownSignals(
const server: Bun.Server<unknown>
server
, {
reportError?: ((report: ShutdownFailure) => unknown) | undefined

Receives each failure of the shutdown — a closer that threw, a server whose stop did — in place of console.error.

The same shape createApp({ reportError }) takes, so one receiver serves both: a report here is source: "shutdown", with no ctx.

shutdown

itself reports nothing — it returns its failures to the caller; only these handlers, which have no caller to return to, need somewhere to put them.

reportError
,
close?: readonly Closer[] | undefined

Resources to release, in order, after the server has stopped.

After, never before: a request still in flight may reach for the pool that closing it early would have taken away.

close
: [() =>
let running: Promise<void> | undefined
running
, () =>
const pool: {
end(): Promise<void>;
}
pool
.end()],
});
const
const job: Bun.CronJob
job
= Bun.
const cron: (schedule: Bun.CronWithAutocomplete, handler: (this: Bun.CronJob) => unknown, options?: Bun.CronOptions) => Bun.CronJob (+1 overload)

Schedule an in-process cron job that calls a function on a schedule.

Unlike the module-path overload, this runs the callback on the current event loop — the job dies with the process and does not survive reboots. State is shared between invocations (closures, module-level variables, database connections all persist).

| | In-process (this overload) | OS-level (path + title) | |---|---|---| | Survives process exit | No | Yes | | Shared state between runs | Yes | No (fresh process each time) | | Windows expression limits | None | 48-trigger cap | | Return type |

CronJob

(sync) | Promise<void> |

No-overlap guarantee

The next fire time is computed only after the callback settles (including any returned Promise). If your callback takes 3 minutes and runs every minute, it fires at T+0 → runs until T+3 → next fire is the first minute boundary after T+3. Invocations never stack.

Error semantics

Matches setTimeout: a synchronous throw emits uncaughtException, a rejected Promise emits unhandledRejection. Without a listener, the process exits with code 1. The job reschedules itself after an error — it does not stop on first failure.

process.on("unhandledRejection", (err) => log.error(err)); // keep going
Bun.cron("* * * * *", async () => { await mightThrow(); });

Cron expression syntax

Five fields: minute hour day-of-month month day-of-week.

| Field | Values | Special chars | |-------|--------|---------------| | Minute | 0-59 | * , - / | | Hour | 0-23 | * , - / | | Day of month | 1-31 | * , - / | | Month | 1-12 or JAN-DEC | * , - / | | Day of week | 0-7 or SUN-SAT | * , - / |

  • 0 and 7 both mean Sunday.
  • Month and weekday names are case-insensitive (MON, Monday, jan, January all work).
  • Nicknames: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly.
  • When both day-of-month and day-of-week are restricted (neither is *), the job fires when either matches — POSIX cron OR semantics.
  • All expressions work on all platforms — there is no Windows trigger limit here.

Lifecycle & --hot

Under bun --hot, all in-process cron jobs are stopped immediately before the module graph is re-evaluated. Each Bun.cron() call still in your source then re-registers, so editing the schedule, editing the callback, or deleting the line entirely all take effect on save without leaking timers.

By default the job keeps the process alive (like setInterval); call .unref() to let the process exit naturally when nothing else is pending.

@param ― schedule Cron expression or nickname (e.g. "*\/5 * * * *", "@hourly").

@param ― handler Function to call on each fire. May return a Promise — the next fire is not scheduled until it settles.

@returns ― A CronJob handle. Chainable: .stop(), .ref(), .unref() all return the job itself.

@throws ― Synchronously if schedule is invalid, or the expression has no future occurrences (e.g. "0 0 30 2 *" — February 30th).

@see ― CronJob for the returned handle.

@see ― Bun.cron.parse to preview the next fire time.

cron
("*/5 * * * *", async () => {
let running: Promise<void> | undefined
running
= sweepExpired(
const shutdown: ShutdownHandle
shutdown
.
stopping: AbortSignal

Aborts the moment a stop is asked for, before anything else happens.

This is what makes preStopDelayMs worth having: a readiness endpoint that reads it starts failing while the delay runs, the balancer stops routing here, and only then does the server stop. Without it the delay postpones the same cut instead of avoiding it.

A standard AbortSignal, so it composes with everything that already takes one — a poll loop, a queue consumer, a fetch to an upstream that is no longer worth waiting for.

It exists only once the server does, and the server is built from the routes: a handler reads it when a request comes in, by which time it is there, and a controller built from its dependencies takes it as a function.

stopping
)
.catch((
error: any
error
) => {
reportError({
source: FailureSource
source
: "job",
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
});
})
.finally(() => {
let running: Promise<void> | undefined
running
= undefined;
});
await
let running: Promise<void>
running
;
});
const shutdown: ShutdownHandle
shutdown
.
stopping: AbortSignal

Aborts the moment a stop is asked for, before anything else happens.

This is what makes preStopDelayMs worth having: a readiness endpoint that reads it starts failing while the delay runs, the balancer stops routing here, and only then does the server stop. Without it the delay postpones the same cut instead of avoiding it.

A standard AbortSignal, so it composes with everything that already takes one — a poll loop, a queue consumer, a fetch to an upstream that is no longer worth waiting for.

It exists only once the server does, and the server is built from the routes: a handler reads it when a request comes in, by which time it is there, and a controller built from its dependencies takes it as a function.

stopping
.addEventListener("abort", () =>
const job: Bun.CronJob
job
.stop(), {
once?: boolean | undefined
once
: true });
  • No new run starts once a shutdown begins. stopping is an AbortSignal that aborts as soon as a shutdown begins, and it stops the job.
  • A run in progress is waited for. Closers run in order after the server has stopped. The first returns the run’s promise, so the pool closes only once the run settles. Closers have no deadline, so a long run checks the signal between batches and returns early.
  • A failed run does not end the process. Bun.cron treats a rejected promise as setTimeout does: an unhandled rejection, which ends the process when nothing listens for it. The catch hands it to the same reportError that createApp was given, and the schedule goes on. See Errors.
  • Runs do not overlap. Bun.cron schedules the next fire only after the callback’s promise settles, so a slow run delays the next one.

Bun.cron reads the expression in the system’s local time unless it is given { tz: "UTC" } as a third argument. It also accepts nicknames such as @hourly and @daily.

A cron expression’s smallest step is a minute. For work every few seconds, use setInterval. It fires whether the last run finished or not, so running doubles as a guard:

let
let running: Promise<void> | undefined
running
: Promise<void> | undefined;
const
const shutdown: ShutdownHandle
shutdown
= onShutdownSignals(
const server: Bun.Server<unknown>
server
, {
close?: readonly Closer[] | undefined

Resources to release, in order, after the server has stopped.

After, never before: a request still in flight may reach for the pool that closing it early would have taken away.

close
: [() =>
let running: Promise<void> | undefined
running
] });
const
const timer: NodeJS.Timeout
timer
= setInterval(() => {
if (
let running: Promise<void> | undefined
running
) return;
let running: Promise<void> | undefined
running
= refreshRates()
.catch((
error: any
error
) => {
reportError({
source: FailureSource
source
: "rates",
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
});
})
.finally(() => {
let running: Promise<void> | undefined
running
= undefined;
});
}, 10_000);
const shutdown: ShutdownHandle
shutdown
.
stopping: AbortSignal

Aborts the moment a stop is asked for, before anything else happens.

This is what makes preStopDelayMs worth having: a readiness endpoint that reads it starts failing while the delay runs, the balancer stops routing here, and only then does the server stop. Without it the delay postpones the same cut instead of avoiding it.

A standard AbortSignal, so it composes with everything that already takes one — a poll loop, a queue consumer, a fetch to an upstream that is no longer worth waiting for.

It exists only once the server does, and the server is built from the routes: a handler reads it when a request comes in, by which time it is there, and a controller built from its dependencies takes it as a function.

stopping
.addEventListener("abort", () => clearInterval(
const timer: NodeJS.Timeout
timer
), {
once?: boolean | undefined
once
: true });

A tick that finds a run still going is skipped, so a slow upstream slows the refresh down instead of piling up runs.

Every instance runs the schedule, so a daily report is sent once per instance. Work that must run once needs a lock that every instance sees, kept where the fleet’s shared state lives. Put it behind a one-method interface, so a test can pass a lock that always answers true:

import { redis } from "bun";
export interface JobLock {
acquire(
name: string
name
: string,
ttlMs: number
ttlMs
: number): Promise<boolean>;
}
export const
const redisLock: JobLock
redisLock
: JobLock = {
acquire: async (
name: string
name
,
ttlMs: number
ttlMs
) =>
(await redis.set(`lock:${
name: string
name
}`, crypto.randomUUID(), "PX", String(
ttlMs: number
ttlMs
), "NX")) === "OK",
};
const
const job: Bun.CronJob
job
= Bun.
const cron: (schedule: Bun.CronWithAutocomplete, handler: (this: Bun.CronJob) => unknown, options?: Bun.CronOptions) => Bun.CronJob (+1 overload)

Schedule an in-process cron job that calls a function on a schedule.

Unlike the module-path overload, this runs the callback on the current event loop — the job dies with the process and does not survive reboots. State is shared between invocations (closures, module-level variables, database connections all persist).

| | In-process (this overload) | OS-level (path + title) | |---|---|---| | Survives process exit | No | Yes | | Shared state between runs | Yes | No (fresh process each time) | | Windows expression limits | None | 48-trigger cap | | Return type |

CronJob

(sync) | Promise<void> |

No-overlap guarantee

The next fire time is computed only after the callback settles (including any returned Promise). If your callback takes 3 minutes and runs every minute, it fires at T+0 → runs until T+3 → next fire is the first minute boundary after T+3. Invocations never stack.

Error semantics

Matches setTimeout: a synchronous throw emits uncaughtException, a rejected Promise emits unhandledRejection. Without a listener, the process exits with code 1. The job reschedules itself after an error — it does not stop on first failure.

process.on("unhandledRejection", (err) => log.error(err)); // keep going
Bun.cron("* * * * *", async () => { await mightThrow(); });

Cron expression syntax

Five fields: minute hour day-of-month month day-of-week.

| Field | Values | Special chars | |-------|--------|---------------| | Minute | 0-59 | * , - / | | Hour | 0-23 | * , - / | | Day of month | 1-31 | * , - / | | Month | 1-12 or JAN-DEC | * , - / | | Day of week | 0-7 or SUN-SAT | * , - / |

  • 0 and 7 both mean Sunday.
  • Month and weekday names are case-insensitive (MON, Monday, jan, January all work).
  • Nicknames: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly.
  • When both day-of-month and day-of-week are restricted (neither is *), the job fires when either matches — POSIX cron OR semantics.
  • All expressions work on all platforms — there is no Windows trigger limit here.

Lifecycle & --hot

Under bun --hot, all in-process cron jobs are stopped immediately before the module graph is re-evaluated. Each Bun.cron() call still in your source then re-registers, so editing the schedule, editing the callback, or deleting the line entirely all take effect on save without leaking timers.

By default the job keeps the process alive (like setInterval); call .unref() to let the process exit naturally when nothing else is pending.

@param ― schedule Cron expression or nickname (e.g. "*\/5 * * * *", "@hourly").

@param ― handler Function to call on each fire. May return a Promise — the next fire is not scheduled until it settles.

@returns ― A CronJob handle. Chainable: .stop(), .ref(), .unref() all return the job itself.

@throws ― Synchronously if schedule is invalid, or the expression has no future occurrences (e.g. "0 0 30 2 *" — February 30th).

@see ― CronJob for the returned handle.

@see ― Bun.cron.parse to preview the next fire time.

cron
("0 6 * * *", async () => {
if (!(await
const redisLock: JobLock
redisLock
.acquire("daily-report", 10 * 60_000))) return;
await sendDailyReport();
});

The first instance to set the key runs the report; the others find it taken and return. The lock is never released: it expires. Keep its time to live longer than a run and shorter than the period — ten minutes against a day here. An instance that dies mid-run leaves the lock to expire, and the job waits for its next fire.

A Postgres advisory lock or a row with a unique key fits the same interface. None of them guarantees the run happens: one that fails after taking the lock is not retried until the next fire. Work that must not be lost belongs in a queue.

A job inside the server shares its process, its memory and its deploys. That makes it cheap, and sometimes wrong:

  • A job that must run when no server does — a nightly export on a service that scales to zero — belongs to the orchestrator: a Kubernetes CronJob, or Bun.cron with a module path and a title, which registers it with the operating system’s scheduler.
  • A job heavy enough to slow requests down — a large import, image processing — belongs in a worker process of its own.
  • A queue consumer is a loop over a transport — Redis streams, SQS, a table polled with for update skip locked — and the framework has no opinion on which. It stops on the same stopping signal.