← all posts

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.

/rootthe working directory every OS-level job ran in, not the project
0bytes of output kept on a box with no mail server
exit 1what one throwing in-process handler did to our Bun.serve app

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 keptRuns in the project dir with .envA throw doesn’t kill the appRun historyBuilt 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 appendFile or your logger’s file transport.
  • Put MAILTO=you@example.com at 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 working sendmail on 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 tz without 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" }) and Bun.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
DependencyNoneOne package
Runs under Node.js✗✓
Seconds field (*/10 * * * * *)✗ (throws on 6 fields)✓
Time zone option{ tz }{ timezone }
Run that’s still going at next tickTick is skippedOverlaps, or noOverlap: true
Error thrown by the jobApp exits unless you catch itLogged, 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 a try/catch in 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 from import.meta.dir, load .env yourself, 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?
It depends on the form. The in-process form, Bun.cron(schedule, handler), prints to your app's stdout like any other code. The OS-level form, Bun.cron(path, schedule, title), runs the script from the system scheduler: on Linux, cron mails stdout and stderr to the local user, and if no mail server is installed, which is the usual case on a VPS or in a container, the output is thrown away. journalctl -u cron only shows that the command started. On macOS, launchd writes it to /tmp/bun.cron.<title>.stdout.log and .stderr.log. On Windows, Event Viewer records that the task ran, not what it printed.
Why is my Bun.cron job not running?
For the OS-level form on Linux, check in order: is a cron daemon running at all (containers and WSL often have none, and Bun registers the job anyway); did you register it as another user, for example with sudo; does crontab -l show the # bun-cron: <title> line; and did it run and fail, because the job starts in your home directory without your .env, with PATH set to /usr/bin:/bin. A script whose default export has no scheduled() method fails every time. For the in-process form, the job only runs while your app does, and an uncaught error in the handler exits the whole process.
What timezone does Bun.cron use?
Both forms use 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. The in-process form and Bun.cron.parse() take { tz: "UTC" } or any IANA name to override it. The OS-level form ignores tz without an error, because crontab has no per-line zone; set the server's zone or convert the hour. In Bun 1.3.12 to 1.3.14, the in-process form read schedules in UTC, so upgrading to 1.4 moved those jobs by your UTC offset.
How do I list or remove a Bun.cron job?
Call await Bun.cron.remove("<title>"); it resolves without an error if the job doesn't exist. To see the jobs on Linux, run crontab -l | grep -A1 '^# bun-cron:'. To remove one by hand, delete the marker comment and the line below it with crontab -e. On macOS each job is ~/Library/LaunchAgents/bun.cron.<title>.plist, and on Windows a Task Scheduler task named bun-cron-<title>.
Should I use Bun.cron or node-cron?
Bun.cron is built in and needs no dependency, and its in-process form skips a tick while the previous run is still going. node-cron 4 also runs under Node.js, accepts a seconds field, which Bun.cron rejects, and logs an error thrown by a job and keeps going, where an uncaught error in an in-process Bun.cron handler exits the process. For a Bun-only server with jobs that catch their own errors, Bun.cron is enough.
How do I keep a log of every Bun.cron run?
RunWisp, an open-source cron replacement that ships as a single binary, covers both forms. Its npm package @runwisp/js runs under Bun and replaces an in-process Bun.cron with a named task: every run is recorded with its output, exit code and duration, a thrown error fails that run instead of exiting your app, and a failure can alert you. For the OS-level form, crontab -l | runwisp import cron turns each Bun.cron job into a task named after its title. Its limits: it's a daemon to keep running, the crontab import is Linux-only (on macOS you write the task by hand), it doesn't run on Windows outside WSL, and it doesn't coordinate across several servers.
  1. 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.

  2. 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.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh