Bun has built-in support for registering OS-level cron jobs and parsing cron expressions.
Quickstart
Parse a cron expression to find the next matching time:
// Next weekday at 9:30 AM UTC
const next = Bun.cron.parse("30 9 * * MON-FRI");
console.log(next); // => 2025-01-20T09:30:00.000Z
Register a 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 UTC Date.
const next = Bun.cron.parse("*/15 * * * *");
console.log(next); // => next quarter-hour boundary
Parameters
| Parameter | Type | Description |
|---|
expression | string | A 5-field cron expression or predefined nickname |
relativeDate | Date | number | Starting point for the search (defaults to Date.now()) |
Returns
Date | null — the next matching UTC time, or null if no match exists within ~4 years (e.g. February 30th).
Chaining calls
Call parse() repeatedly to get a sequence of upcoming times:
const from = Date.UTC(2025, 0, 15, 10, 0, 0);
const first = Bun.cron.parse("0 * * * *", from);
console.log(first); // => 2025-01-15T11:00:00.000Z
const second = Bun.cron.parse("0 * * * *", first);
console.log(second); // => 2025-01-15T12:00:00.000Z
Cron expression syntax
Standard 5-field format: minute hour day-of-month month day-of-week
| Field | Values | Special characters |
|---|
| 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 | * , - / |
Special characters
| Character | Description | Example |
|---|
* | All values | * * * * * — every minute |
, | List | 1,15 * * * * — minute 1 and 15 |
- | Range | 9-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
| Nickname | Equivalent | Description |
|---|
@yearly / @annually | 0 0 1 1 * | Once a year (January 1st) |
@monthly | 0 0 1 * * | Once a month (1st day) |
@weekly | 0 0 * * 0 | Once a week (Sunday) |
@daily / @midnight | 0 0 * * * | Once a day (midnight) |
@hourly | 0 * * * * | Once an hour |
const next = Bun.cron.parse("@daily");
console.log(next); // => next midnight UTC
Day-of-month and day-of-week interaction
When both day-of-month and day-of-week are specified (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 only one is specified (the other is *), only that field is used for matching.
Bun.cron()
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
| Parameter | Type | Description |
|---|
path | string | Path to the script (resolved relative to caller) |
schedule | string | Cron expression or nickname |
title | string | Unique job identifier (alphanumeric, hyphens, underscores) |
Re-registering with the same title overwrites the existing job in-place — the old schedule is replaced, not duplicated.
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:
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.
Linux
Bun uses crontab to register jobs. Each job is stored 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:
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. Each job is installed 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 via 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. Each job is registered 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
Tasks are registered using S4U (Service-for-User) logon type, which runs jobs as the registering user even when not logged in — matching Linux crontab behavior. 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 headless servers and CI environments where the current user’s Security Identifier (SID) cannot be resolved — such as service accounts created by NSSM or similar tools — Bun.cron() will fail 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:
| Pattern | Trigger strategy | Count |
|---|
*/5 * * * * | Single trigger with Repetition (PT5M) | 1 |
*/15 * * * * | Single trigger with Repetition (PT15M) | 1 |
0 9 * * MON-FRI | One CalendarTrigger per weekday | 5 |
0,30 9-17 * * * | 2 minutes × 9 hours | 18 |
@daily, @weekly, @monthly, @yearly | Single trigger | 1 |
Expressions that fail on Windows (but work on Linux and macOS):
| Pattern | Why | Trigger count |
|---|
*/7 * * * * | 9 minute values × 24 hours | 216 |
*/8 * * * * | 8 minute values × 24 hours | 192 |
*/9 * * * * | 7 minute values × 24 hours | 168 |
*/11 * * * * | 6 minute values × 24 hours | 144 |
*/13 * * * * | 5 minute values × 24 hours | 120 |
*/15 * * 6 * | Month restriction prevents Repetition: 4 × 24 | 96 |
0,30 * 15 * FRI | OR-split doubles triggers: 2 × 24 × 2 | 96 |
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 and work regardless of other fields. 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:
| Platform | What remove() does |
|---|
| Linux | Edits crontab to remove the entry and its marker comment |
| macOS | Runs launchctl bootout and deletes the plist file |
| Windows | Runs schtasks /delete to remove the scheduled task |
Removing a job that doesn’t exist resolves without error.