Cron

Schedule and parse cron jobs with Bun

Bun has built-in support for cron — parse expressions, run a callback on a schedule inside your process, or register OS-level jobs that survive restarts.

Quickstart#

Run a callback on a schedule in the current process:

Bun.cron("0 * * * *", async () => {
  await cleanupTempFiles();
});

Parse a cron expression to find the next matching time:

// Next weekday at 9:30 AM local time
const next = Bun.cron.parse("30 9 * * MON-FRI");

Register an OS-level cron job that runs a script on a schedule:

await Bun.cron("./worker.ts", "30 2 * * MON", "weekly-report");

Bun.cron.parse()#

Parse a cron expression and return the next matching Date in the system's local time zone.

const next = Bun.cron.parse("*/15 * * * *");
console.log(next); // => next quarter-hour boundary

Parameters#

ParameterTypeDescription
expressionstringA 5-field cron expression or predefined nickname
relativeDateDate | numberStarting point for the search (defaults to Date.now())
options{ tz?: string }IANA time-zone name to interpret the schedule in (defaults to the system zone)

Returns#

Date | null — the next matching time, or null if no match exists within 8 years (for example, February 30th).

Chaining calls#

Call parse() repeatedly to get a sequence of upcoming times:

let cursor: Date | number = Date.now();
for (let i = 0; i < 3; i++) {
  cursor = Bun.cron.parse("0 * * * *", cursor)!;
  console.log(cursor.toLocaleString()); // next three top-of-hour boundaries
}

Cron expression syntax#

Standard 5-field format: minute hour day-of-month month day-of-week

FieldValuesSpecial characters
Minute059* , - /
Hour023* , - /
Day of month131* , - /
Month112 or JANDEC* , - /
Day of week07 or SUNSAT* , - /

Special characters#

CharacterDescriptionExample
*All values* * * * * — every minute
,List1,15 * * * * — minute 1 and 15
-Range9-17 * * * * — minutes 9 through 17
/Step*/15 * * * * — every 15 minutes

Named values#

Month and weekday fields accept case-insensitive names:

// 3-letter abbreviations
Bun.cron.parse("0 9 * * MON-FRI"); // weekdays
Bun.cron.parse("0 0 1 JAN,JUN *"); // January and June

// Full names
Bun.cron.parse("0 9 * * Monday-Friday");
Bun.cron.parse("0 0 1 January *");

Both 0 and 7 mean Sunday in the weekday field.

Predefined nicknames#

NicknameEquivalentDescription
@yearly / @annually0 0 1 1 *Once a year (January 1st)
@monthly0 0 1 * *Once a month (1st day)
@weekly0 0 * * 0Once a week (Sunday)
@daily / @midnight0 0 * * *Once a day (midnight)
@hourly0 * * * *Once an hour
const next = Bun.cron.parse("@daily");
console.log(next); // => next local midnight

Time zone#

Bun interprets schedules in the system's local time zone, the same way crontab, launchd, and Windows Task Scheduler read them. The OS-level form and the in-process callback form fire at the same wall-clock time.

To override, pass an IANA time-zone name as { tz } to Bun.cron.parse() or the in-process Bun.cron(schedule, handler, options):

// 09:00 UTC, regardless of the server's TZ
Bun.cron.parse("0 9 * * *", Date.now(), { tz: "UTC" });

// Fire at 09:00 New York time
Bun.cron("0 9 * * *", handler, { tz: "America/New_York" });

DST transitions:

  • Spring-forward — a schedule that lands in the missing hour fires that day, shifted forward by the gap (e.g. 30 2 * * * runs at 3:30 on the spring-forward day). For multi-minute patterns inside the gap (*/15 2 * * *), only the first match fires.
  • Fall-back — a fixed-time schedule in the duplicated hour (30 1 * * *) fires once, at the first occurrence. A schedule whose minute or hour field is * (0 * * * *, * * * * *) fires through both occurrences — once per real-time minute, matching crontab on Linux.

Day-of-month and day-of-week interaction#

When you specify both day-of-month and day-of-week (neither is *), the expression matches when either condition is true. This follows the POSIX cron standard.

// Fires on the 15th of every month OR every Friday
Bun.cron.parse("0 0 15 * FRI");

When you specify only one (the other is *), Bun uses only that field for matching.


Bun.cron(schedule, handler) — in-process#

Run a callback on a cron schedule inside the current process.

const job = Bun.cron("*/5 * * * *", async () => {
  await syncToDatabase();
});

In-process scheduling is the lightweight option for long-running servers and workers. It requires no system cron daemon, works the same on every platform, and shares state (database pools, caches, module-level variables) between invocations.

In-processOS-level
Survives process exit/rebootNoYes
Shared state between runsYesNo (fresh process each time)
Platform requirementsNonecrontab / launchd / Task Scheduler
Windows expression limitsNone48-trigger cap
Return typeCronJobPromise<void>

Parameters#

ParameterTypeDescription
schedulestringA cron expression or nickname like "@hourly".
handler(this: CronJob) => unknownCalled on each fire. May return a Promise; Bun schedules the next fire only once it settles. Inside a function callback, this is the CronJob (so this.stop() works).
options{ tz?: string }IANA time-zone name to interpret the schedule in (defaults to the system zone).

Returns a CronJob synchronously. Throws a TypeError if the expression is invalid, the time-zone name is unknown, or the expression has no future occurrences, like "0 0 30 2 *" (February 30th).

No-overlap guarantee#

Bun computes the next fire time only after the handler and any returned Promise settle. If your handler takes 90 seconds and the schedule is * * * * *, the second fire is the first minute boundary after the handler finishes, not 60 seconds after the first fire. Invocations never stack.

Error handling#

Errors match setTimeout semantics:

  • A synchronous throw emits process.on("uncaughtException").
  • A rejected returned Promise emits process.on("unhandledRejection").

Without a listener, the process exits with code 1. With a listener, the job keeps running — it does not stop on the first failure.

process.on("unhandledRejection", err => log.error("cron failed:", err));

Bun.cron("* * * * *", async () => {
  await mightThrow(); // logged and retried next minute
});

bun --hot#

Under bun --hot, Bun stops all in-process cron jobs immediately before the module graph re-evaluates. Every Bun.cron() call still in your source then re-registers. Editing the schedule, editing the handler, or deleting the line entirely all take effect on save without leaking timers.

The CronJob handle#

using job = Bun.cron("0 * * * *", () => {});

job.cron; // => "0 * * * *"
job.stop(); // cancel — the handler will not fire again
job.unref(); // allow the process to exit even while scheduled
job.ref(); // keep the process alive (default)

CronJob is Disposableusing job = Bun.cron(...) auto-stops at scope exit. stop(), ref(), and unref() all return the job for chaining.

Fake timers#

In-process cron honors jest.useFakeTimers(). setSystemTime(), advanceTimersByTime(), and runAllTimers() control when it fires, so you can test scheduled callbacks without waiting on the real clock.


Bun.cron(path, schedule, title) — OS-level#

Register an OS-level cron job that runs a JavaScript/TypeScript module on a schedule.

await Bun.cron("./worker.ts", "30 2 * * MON", "weekly-report");

Parameters#

ParameterTypeDescription
pathstringPath to the script (resolved relative to caller)
schedulestringCron expression or nickname
titlestringUnique job identifier (alphanumeric, hyphens, underscores)

Re-registering with the same title overwrites the existing job in-place. Bun replaces the old schedule instead of duplicating it.

await Bun.cron("./worker.ts", "0 * * * *", "my-job"); // every hour
await Bun.cron("./worker.ts", "*/15 * * * *", "my-job"); // replaces: every 15 min

The scheduled() handler#

The registered script must export a default object with a scheduled() method, following the Cloudflare Workers Cron Triggers API:

worker.ts
export default {
  scheduled(controller: Bun.CronController) {
    console.log(controller.cron); // "30 2 * * 1"
    console.log(controller.type); // "scheduled"
    console.log(controller.scheduledTime); // 1737340201847 (Date.now() at invocation)
  },
};

The handler can be async. Bun waits for the returned promise to settle before exiting.


How it works per platform#

Linux#

Bun uses crontab to register jobs. Bun stores each job as a line in your user's crontab with a # bun-cron: <title> marker comment above it.

The crontab entry looks like:

<schedule> '<bun-path>' run --cron-title=<title> --cron-period='<schedule>' '<script-path>'

When the cron daemon fires the job, Bun imports your module and calls the scheduled() handler.

Viewing registered jobs:

crontab -l

Logs: On Linux, cron output goes to the system log. Check with:

# systemd-based (Ubuntu, Fedora, Arch, etc.)
journalctl -u cron       # or crond on some distros
journalctl -u cron --since "1 hour ago"

# syslog-based (older systems)
grep CRON /var/log/syslog

To capture stdout/stderr to a file, redirect output in the crontab entry directly, or add logging inside your scheduled() handler.

Manually uninstalling without code:

# Edit your crontab and remove the "# bun-cron: <title>" comment
# and the command line below it
crontab -e

# Or remove ALL bun cron jobs at once by filtering them out:
crontab -l | grep -v "# bun-cron:" | grep -v "\-\-cron-title=" | crontab -

macOS#

Bun uses launchd to register jobs. Bun installs each job as a plist file at:

~/Library/LaunchAgents/bun.cron.<title>.plist

The plist uses StartCalendarInterval to define the schedule. Complex patterns with ranges, lists, or steps are supported. Bun expands them into multiple StartCalendarInterval dicts as a Cartesian product.

Viewing registered jobs:

launchctl list | grep bun.cron

Logs: stdout and stderr are written to:

/tmp/bun.cron.<title>.stdout.log
/tmp/bun.cron.<title>.stderr.log

For example, a job titled weekly-report:

cat /tmp/bun.cron.weekly-report.stdout.log
tail -f /tmp/bun.cron.weekly-report.stderr.log

Manually uninstalling without code:

# Unload the job from launchd
launchctl bootout gui/$(id -u)/bun.cron.<title>

# Delete the plist file
rm ~/Library/LaunchAgents/bun.cron.<title>.plist

# Example for a job titled "weekly-report":
launchctl bootout gui/$(id -u)/bun.cron.weekly-report
rm ~/Library/LaunchAgents/bun.cron.weekly-report.plist

Windows#

Bun uses Windows Task Scheduler with XML-based task definitions. Bun registers each job as a scheduled task named bun-cron-<title> using CalendarTrigger elements and Repetition patterns.

Most cron expressions are fully supported, including @daily, @weekly, @monthly, @yearly, ranges (1-5), lists (1,15), named days/months, and day-of-month patterns.

User context#

Bun registers tasks with the S4U (Service-for-User) logon type, which runs jobs as the registering user even when not logged in. Linux crontab behaves the same way. No password is stored.

TCP/IP networking (fetch(), HTTP, WebSocket, database connections) works normally. The only restriction is that S4U tasks cannot access Windows-authenticated network resources (SMB file shares, mapped drives, Kerberos/NTLM services).

On some headless servers and CI environments, the current user's Security Identifier (SID) cannot be resolved, for example with service accounts created by NSSM or similar tools. In that case, Bun.cron() fails with an error explaining the issue. To work around this, either run Bun as a regular user account, or create the scheduled task manually with schtasks /create /xml <file> /tn <name> /ru SYSTEM /f.

Trigger limit#

Windows Task Scheduler enforces a limit of 48 triggers per task (the CalendarTrigger element has maxOccurs="48"). Some cron expressions that work on Linux and macOS exceed this limit on Windows. When a pattern exceeds the limit, Bun.cron() rejects it with an error message.

Expressions that work on all platforms:

PatternTrigger strategyCount
*/5 * * * *Single trigger with Repetition (PT5M)1
*/15 * * * *Single trigger with Repetition (PT15M)1
0 9 * * MON-FRIOne CalendarTrigger listing all five weekdays1
0,30 9-17 * * *2 minutes × 9 hours18
@daily, @weekly, @monthly, @yearlySingle trigger1

Expressions that fail on Windows (but work on Linux and macOS):

PatternWhyTrigger count
*/7 * * * *9 minute values × 24 hours216
*/8 * * * *8 minute values × 24 hours192
*/9 * * * *7 minute values × 24 hours168
*/11 * * * *6 minute values × 24 hours144
*/13 * * * *5 minute values × 24 hours120
*/15 * * 6 *Month restriction prevents Repetition: 4 × 2496
0,30 * 15 * FRIOR-split doubles triggers: 2 × 24 × 296

The key factor is whether the expression can use a Repetition interval (single trigger) or must expand to individual CalendarTrigger elements. Minute steps that evenly divide 60 (*/1, */2, */3, */4, */5, */6, */10, */12, */15, */20, */30) use Repetition only when the other four fields are unrestricted (*). Steps that don't divide 60 (*/7, */8, */9, */11, */13, etc.) must be expanded, and with 24 hours active, the count quickly exceeds 48.

To work around it, simplify the expression or restrict the hour range:

// ❌ Fails on Windows: */7 with all hours = 216 triggers
await Bun.cron("./job.ts", "*/7 * * * *", "my-job");

// ✅ Works: restrict to specific hours (9 values × 5 hours = 45 triggers)
await Bun.cron("./job.ts", "*/7 9-13 * * *", "my-job");

// ✅ Works: use a divisor of 60 instead (Repetition, 1 trigger)
await Bun.cron("./job.ts", "*/5 * * * *", "my-job");

Windows containers#

Bun.cron() is not supported in Windows Docker containers. The Task Scheduler service is not running in servercore or nanoserver images. Use an in-process scheduler for containerized workloads.

Viewing registered jobs:

schtasks /query /tn "bun-cron-<title>"

# List all bun cron tasks
schtasks /query | findstr "bun-cron-"

Manually uninstalling without code:

schtasks /delete /tn "bun-cron-<title>" /f

# Example:
schtasks /delete /tn "bun-cron-weekly-report" /f

Or open Task Scheduler (taskschd.msc), find the task named bun-cron-<title>, right-click, and delete it.


Bun.cron.remove()#

Remove a previously registered cron job by its title. Works on all platforms.

await Bun.cron.remove("weekly-report");

This reverses what Bun.cron() did:

PlatformWhat remove() does
LinuxEdits crontab to remove the entry and its marker comment
macOSRuns launchctl bootout and deletes the plist file
WindowsRuns schtasks /delete to remove the scheduled task

Removing a job that doesn't exist resolves without error.