Bun.cron logs and Bun.cron not running: where the output goes and 8 things to check
Bun.cron has two forms that behave differently. The OS-level one writes to your crontab, runs from your home directory without your .env, and its output goes to cron's mail. Where the logs are on Linux and macOS, why a job didn't run, timezones, removal, and Bun.cron vs node-cron.
12 min read
You registered a nightly job with Bun.cron("./worker.ts", "0 3 * * *", "nightly"). It’s in
crontab -l. Three nights later the report still hasn’t shown up, and there’s no log to tell you
why. console.log in the worker printed nowhere you can find.
Bun.cron shipped in Bun 1.3.11 in March 2026, and so far Bun’s own docs are about the only
thing written about it. They’re short on what happens after the job is handed to cron. We
registered jobs in a Debian container with Bun 1.4.2 and cron, and read what came out.
Short answer
Bun.cron has two forms. The OS-level one, Bun.cron(path, schedule, title), writes a line into
your user’s crontab on Linux (a launchd plist on macOS, a scheduled task on Windows). The job then
runs as a new process started from your home directory, with cron’s bare environment, no
.env, and output that cron mails to you, which on a server without a mail setup means it’s
thrown away. On macOS the output is in /tmp/bun.cron.<title>.stdout.log. The in-process form,
Bun.cron(schedule, handler), runs inside your app and prints to its stdout, but an error that
escapes the handler exits the app. Both read schedules in local time (the in-process form switched from UTC in Bun 1.4.0).
If you want each run’s output and exit code kept and a failed run to alert you, RunWisp, an open-source cron replacement that ships as a single binary, can run both forms. Details below.
| Output kept | Runs in the project dir with .env | A throw doesn’t kill the app | Run history | Built into Bun | |
|---|---|---|---|---|---|
OS-level Bun.cron on Linux | ✗ | ✗ | ✓ | ✗ | ✓ |
In-process Bun.cron | ✓ | ✓ | ✗ | ✗ | ✓ |
| node-cron under Bun | ✓ | ✓ | ✓ | ✗ | ✗ |
RunWisp (@runwisp/js or TOML) | ✓ | ✓ | ✓ | ✓ | ✗ |
“Output kept” means you can read what a run printed after it’s over. The OS-level form can’t take down your app because it’s a separate process, but it isn’t your app’s process either. That’s where most of the surprises below come from.
Two functions with one name
Bun 1.3.11 added the OS-level form. You give it a script, a schedule and a title:
// register.ts, run once: bun register.ts
await Bun.cron("./worker.ts", "0 3 * * *", "nightly");
// worker.ts
export default {
async scheduled(controller) {
// controller.cron === "0 3 * * *"
await sendReport();
},
};
On Linux, Bun appends two lines to your crontab. This is what crontab -l showed in our
container:
# bun-cron: nightly
0 3 * * * '/usr/local/bin/bun' run --cron-title=nightly --cron-period='0 3 * * *' '/app/worker.ts'
Your app doesn’t need to be running when the job fires. Cron starts a fresh bun, which imports
the script and calls default.scheduled(), the same shape as Cloudflare Workers’ Cron Triggers.
Bun 1.3.12 added the in-process form, which is a timer in your running app:
Bun.cron("*/15 * * * *", async () => {
await syncInventory();
});
Same name, different everything: different process, different environment, different place for the output. When a search result says “Bun.cron”, check which one it means.
Where the output goes
Linux: cron’s mail, which usually means nowhere
Bun’s release notes point you at journalctl -u cron for the OS-level form. That journal has a
line saying cron started the command (on Debian and Ubuntu; the unit is crond on Fedora and
RHEL). It doesn’t have anything the job printed.
What the job prints goes where any cron job’s output goes: cron collects stdout and stderr and
mails them to the crontab’s owner. Our container had Exim installed, so the worker’s output turned
up in /var/mail:
Subject: Cron <root@bb412cfba03b> '/usr/local/bin/bun' run --cron-title=every-minute --cron-period='* * * * *' '/app/worker.ts'
X-Cron-Env: <HOME=/root>
X-Cron-Env: <PATH=/usr/bin:/bin>
[worker] tick 2026-10-25T17:31:01.624Z cwd=/root DB=(unset) PATH=/usr/bin:/bin
[worker] something on stderr
Then we removed Exim, the way a minimal VPS or a container image comes, and the next run’s output went nowhere. Debian’s cron notes “No MTA installed, discarding output” in syslog, if there is a syslog. Where cron logs live covers this in more detail.
You can’t fix this by adding >> /var/log/nightly.log 2>&1 to Bun’s line, since registering
the same title again rewrites it, so the redirect lasts only until the next registration. What works:
- Log to a file from the worker, with an absolute path, using
appendFileor your logger’s file transport. - Put
MAILTO=you@example.comat the top of the crontab. Bun keeps lines it didn’t write (we checked: a variable and a job of our own survived re-registering), but you need a workingsendmailon the box. - Run the script from something that keeps output (see the RunWisp section).
macOS, Windows and the in-process form
On macOS, per Bun’s docs, each job is a plist at ~/Library/LaunchAgents/bun.cron.<title>.plist,
and launchd writes the output to /tmp/bun.cron.<title>.stdout.log and .stderr.log. Those files
are easy to find, but they grow without limit. On Windows
the job is a Task Scheduler task named bun-cron-<title>, and Event Viewer records that it ran,
not what it printed. We tested on Linux only.
The in-process form prints to your app’s stdout, mixed into the rest of your logs, so it ends up
in docker logs, journalctl -u yourapp or pm2 logs like anything else.
Why the job didn’t run: 8 things to check
The first six are about the OS-level form on Linux.
1. Nothing runs cron. Bun writes the crontab whether or not a cron daemon is running. We
registered a job before starting cron and got no error, just a job that never fired. Containers
and WSL without systemd usually have no cron running. If the crontab program isn’t installed at
all, registering fails with crontab not found in PATH. Check with pgrep -a cron.
2. It’s in another user’s crontab. sudo bun register.ts puts the job in root’s crontab, and
crontab -l as yourself won’t show it. Look with sudo crontab -l too, and register as the user
the job should run as.
3. It ran, from your home directory. Cron starts jobs in $HOME, and Bun doesn’t change
directory first. A worker that read ./data.json failed every time:
[fail] reading ./data.json from /root
ENOENT: no such file or directory, open './data.json'
at scheduled (/app/fail.ts:5:17)
Build paths from the script’s own location with import.meta.dir:
import { join } from "node:path";
const data = await Bun.file(join(import.meta.dir, "data.json")).json();
4. Your .env isn’t loaded. Bun loads .env from the current directory, and the current
directory is $HOME. With a .env next to the worker, DATABASE_URL was still unset. Cron also
passes no variables from your shell or your app’s environment. Load the file yourself at the
start of the handler:
import { join } from "node:path";
export default {
async scheduled() {
process.loadEnvFile(join(import.meta.dir, ".env"));
const { db } = await import("./db"); // imported after the env is set
await db.cleanup();
},
};
The dynamic import matters. Static imports run before any of your code, so a module that reads
process.env.DATABASE_URL when it loads would still see nothing.
5. PATH is /usr/bin:/bin. bun itself is called by its full path, so it starts. But
Bun.spawn(["pg_dump", …]) or a $ shell call to anything in /usr/local/bin or ~/.bun/bin
fails with “command not found”. Use full paths, or set process.env.PATH in the handler.
6. The script doesn’t export scheduled(). The top level of the file runs, then:
error: Module does not export default.scheduled()
That error is in the output you can’t see (see above), so check that the handler is
export default { scheduled() {} }. A default export without scheduled, or a bare default function, fails with this error. The paths in the
crontab line are also absolute, both the script’s and bun’s. If you deploy to a new directory
or install Bun somewhere else, register the job again.
The last two are about the in-process form.
7. The handler threw, and the app exited. An error that escapes an in-process handler becomes
an uncaughtException or unhandledRejection, and with no listener Bun exits with code 1. That
takes the rest of the app with it. We ran a Bun.serve() app with an every-minute job that
rejected:
listening 44663 pid=290208
[job] tick 2026-10-25T17:33:00.002Z
error: db timeout
at <anonymous> (/tmp/buncron-inproc/server.ts:5:13)
shell exit=1
One failed run, and the web server is gone until something restarts it. Wrap the handler body in
try/catch. A process.on("unhandledRejection") listener also keeps the app up, but for every
error in the app, not just cron’s.
8. The job only lives as long as the process. In-process jobs stop when the app stops, aren’t
caught up afterwards, and stop and re-register on every bun --hot reload. If you run several
copies of the app, every copy runs every job.
The PM2 cluster post covers that problem for
node-cron, and the same fixes apply.
It fired at the wrong hour
Both forms read schedules in the system’s local time zone. The OS-level form always did, and the in-process form switched from UTC in Bun 1.4.0. 0 3 * * * on a
server set to Europe/Berlin is 03:00 Berlin time, and 03:00 UTC on a server set to UTC. Check
the server’s zone with timedatectl.
Three things to watch for:
- The OS-level form ignores
tzwithout saying so. We passed{ tz: "UTC" }as a fourth argument. Bun accepted it, and the crontab line was the same as without it. Crontab has no per-line time zone, so convert the hour or change the server’s zone. - The in-process form takes
tz.Bun.cron("0 9 * * *", handler, { tz: "UTC" })andBun.cron.parse(expr, from, { tz })work as documented. - It used to be UTC. In Bun 1.3.12 to 1.3.14 the in-process form and
Bun.cron.parse()read schedules in UTC, and the 1.3.11 release notes still have a “9 AM UTC” comment. If you wrote in-process jobs then and upgraded to 1.4, they now run at a different time, shifted by your UTC offset. Add{ tz: "UTC" }to get the old behavior back.
Local time means daylight saving time applies too. Bun’s docs say a time skipped in spring fires late and a fixed time in autumn fires once. Cron, which runs the OS-level form, has its own rules.
Listing and removing jobs
# every Bun.cron job in your crontab, with its schedule
crontab -l | grep -A1 '^# bun-cron:'
await Bun.cron.remove("nightly");
remove() edits the crontab on Linux, runs launchctl bootout and deletes the plist on macOS,
and deletes the scheduled task on Windows. Removing a title that doesn’t exist resolves without an
error, so a typo in the title won’t tell you anything. Run crontab -l again to confirm the job is
gone.
To remove one by hand, delete the # bun-cron: comment and the line under it in crontab -e, or:
crontab -l | sed '/^# bun-cron: nightly$/,+1d' | crontab -
Find jobs by title, not by schedule. Bun rewrites some expressions when it writes them: we
registered */5 * * * * and the crontab line said 0,5,10,15,20,25,30,35,40,45,50,55 * * * *.
Registering a title again replaces its line and moves it to the end of the crontab.
Bun.cron vs node-cron
node-cron 4 runs under Bun too. We ran both side by side with the same throwing job:
Bun.cron (in-process) | node-cron 4.6 | |
|---|---|---|
| Dependency | None | One package |
| Runs under Node.js | ✗ | ✓ |
Seconds field (*/10 * * * * *) | ✗ (throws on 6 fields) | ✓ |
| Time zone option | { tz } | { timezone } |
| Run that’s still going at next tick | Tick is skipped | Overlaps, or noOverlap: true |
| Error thrown by the job | App exits unless you catch it | Logged, the schedule goes on |
| OS-level jobs that outlive the app | ✓ (the other Bun.cron) | ✗ |
For a Bun-only app whose handlers catch their own errors, Bun.cron is enough and there’s nothing
to install. If the code also runs on Node.js, or you need seconds, node-cron is the one to use.
Keep a record of every run with RunWisp
RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single binary, with a web UI and a CLI that record every run: its output, exit code and duration. It handles both forms.
For the in-process form, @runwisp/js runs under Bun and starts RunWisp next to your app.
Name the task and keep the schedule:
bun add @runwisp/js
import { RunWisp } from "@runwisp/js";
const runwisp = new RunWisp({ exitOnSignal: true });
// was: Bun.cron("*/15 * * * *", () => syncInventory());
runwisp.task("sync-inventory", { cron: "*/15 * * * *", on_overlap: "skip" }, async () => {
await syncInventory();
});
on_overlap: "skip" matches what Bun.cron does with a run that’s still going. We ran the
throwing job from point 7 this way, next to the same Bun.serve(). Each run failed, the server
kept answering, and the run log had the reason:
$ bunx runwisp logs sync-inventory --data .runwisp
syncing from /tmp/buncron-inproc
Error: db timeout
at <anonymous> (/tmp/buncron-inproc/rw.ts:6:13)
[WARN] run failed task=sync-inventory run=01M5T28AYQEDZ2EWEA05CTJAN3 exit=1 reason=failed dur=1ms
For the OS-level form on Linux, import the crontab Bun wrote. Each job becomes a task named after its title:
$ crontab -l | runwisp import cron
[tasks.nightly]
cron = "0 3 * * *"
env_base = "clean"
working_dir = "~"
catch_up = 0 # cron never re-fires a missed tick
on_overlap = "queue" # cron would overlap instead of queueing
run = "'/usr/local/bin/bun' run --cron-title=nightly --cron-period='0 3 * * *' '/app/worker.ts'"
The import copies cron’s behavior, home directory included. Change the two lines that caused
problems 3 and 4, then remove the job from crontab with Bun.cron.remove("nightly") so it doesn’t
run twice:
working_dir = "/app"
env_file = "/app/.env"
Now the worker runs from the project with its .env, and what it prints is kept with the run, in
runwisp logs nightly and the web UI. Add a notifier and a failed run alerts you on Slack,
Discord, Telegram, email or a webhook. catch_up = 1 runs a job once after the machine was off
at its time, which cron doesn’t do. On macOS the import doesn’t apply, so you write the task
yourself; the Bun guide has the TOML.
The limits:
- It’s another process to keep running: a daemon for the TOML route, or one your app starts for
@runwisp/js. - It runs on Linux and macOS, and on Windows only under WSL.
- It doesn’t coordinate across servers. Two servers with the same task each run it.
- It isn’t a job queue. For fan-out or retries per item, use BullMQ.
Which one to use
- A long-running Bun server with a few jobs: the in-process
Bun.cron, with atry/catchin every handler. That’s all a lot of apps need. - A job that has to run when the app isn’t running: the OS-level
Bun.cron. Build paths fromimport.meta.dir, load.envyourself, and log to a file you can find. - Code that also runs on Node.js, or schedules with seconds: node-cron.
- You want to see what every run printed, and hear about failures: RunWisp. The quick start covers the standalone daemon, and the code is on GitHub.
Common questions
Where does Bun.cron output go?
Why is my Bun.cron job not running?
What timezone does Bun.cron use?
How do I list or remove a Bun.cron job?
Should I use Bun.cron or node-cron?
How do I keep a log of every Bun.cron run?
Read next
-
Scheduling restic or borg backups with alerts when they fail: systemd timer, cron or borgmatic
Four ways to run restic or borg on a schedule and hear about it when a backup fails or never runs: a systemd timer with OnFailure=, cron with a Healthchecks ping, resticprofile and borgmatic. Tested with restic 0.18 and 0.19, including exit 3, stale locks and catch-up after downtime.
-
Django periodic tasks without Celery or Redis: 5 Docker Compose setups
You need clearsessions every night and a report every 15 minutes, and every tutorial adds Celery, beat and Redis. Five setups without them, each with a Compose file: a management command plus supercronic, APScheduler (and why it runs 3 times under gunicorn), Huey on SQLite, django-q2 on your database, and RunWisp.