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.
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");
asyncfunctionsweepExpired(
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).
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.
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 ― 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(
constshutdown: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;
});
constshutdown: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", () =>
constjob: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
constshutdown:ShutdownHandle
shutdown=onShutdownSignals(
constserver: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
consttimer: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);
constshutdown: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.
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:
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.
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.
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.