Semola

Cron

In-process and OS-level cron schedules on Bun

Schedule work either in-process or through the operating system. Cron runs a handler inside the current Bun process. CronOS registers a script with Bun's OS-level cron helper so it runs independently of the current process. CronDistributed runs an in-process handler like Cron, but uses Redis so only one replica executes each scheduled tick.

Import

import { Cron, CronDistributed, CronOS, cronJobBuilder, any, number } from "semola/cron";

Quick start

This starts an in-process daily handler. Calling stop() later cancels its timer.

const daily = new Cron({
  name: "daily-report",
  schedule: "@daily",
  handler: async () => {
    await sendReport();
  },
});

daily.run();

// later
daily.stop();

Schedules

Aliases

@yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly, @minutely. You can also pass a standard cron expression string.

Builder

For typed schedule pieces:

This builds a Monday-Friday 09:00 expression and gives it to an in-process job.

import {
  cronJobBuilder,
  any,
  list,
  number,
  range,
  step,
  Month,
  WeekDay,
} from "semola/cron";

const schedule = cronJobBuilder((b) =>
  b
    .minute(number(0))
    .hour(number(9))
    .weekday(range({ min: WeekDay.mon, max: WeekDay.fri })),
);

const job = new Cron({
  name: "weekday-nudge",
  schedule,
  handler: () => console.log("good morning"),
});

Helpers: any, list, number, range, step, plus Month and WeekDay enums. Unset fields default to *.

list() selects exact values, range() selects an inclusive span, and step() repeats through a span at a fixed interval.

const schedule = cronJobBuilder((b) =>
  b
    .minute(step({ range: { min: 0, max: 59 }, step: 15 }))
    .hour(list((values) => values.number(9).number(17)))
    .day(range({ min: 1, max: 7 }))
    .month(number(Month.jan))
    .weekday(number(WeekDay.mon)),
);

Lifecycle

next() returns the next fire time, or null if there is no match. ref() / unref() control whether the timer keeps the process alive. getStatus(), getExpression(), and getJobName() inspect the job. Disposing the instance (Symbol.dispose) calls stop.

OS crontab

CronOS registers a job with Bun's OS cron helper. It needs a script path, plus name and schedule (no in-process handler):

This registers backup.ts at OS level, then removes that registration.

import { CronOS } from "semola/cron";

const job = new CronOS({
  name: "nightly-backup",
  schedule: "@daily",
  path: "./scripts/backup.ts",
});

await job.run();
await job.stop();

Distributed cron

In a multi-replica deployment, plain Cron runs the handler on every replica. CronDistributed wraps the same in-process schedule with a Redis lock keyed by job name, resolved expression, and tick, so only one replica runs each fire time.

Each replica still schedules locally. When a tick fires, the replica that acquires SET key NX PX runs the handler; the others skip that tick.

import { CronDistributed } from "semola/cron";

const report = new CronDistributed({
  name: "daily-report",
  schedule: "@daily",
  redis,
  handler: async () => {
    await sendReport();
  },
});

report.run();

Use replicaId to identify the lock owner in Redis (defaults to a random UUID per instance). lockTTL (default five minutes) bounds how long the deduplication lock is held. The lock is acquired before handler() runs and is not renewed while the handler executes, so deduplication is at-most-once only while that Redis key remains valid. If a replica is delayed past lockTTL, another replica can acquire the same tick key and run the handler again. Lock keys use the resolved cron expression and scheduled tick time so replicas coordinate on the same key when they fire within about a minute of the boundary. Handlers should be idempotent.

Use a unique name per job.

const cleanup = new CronDistributed({
  name: "cleanup",
  schedule: "@hourly",
  redis,
  replicaId: process.env.REPLICA_ID,
  handler: async () => {
    await purgeStaleRows();
  },
});

CronDistributed shares run(), stop(), next(), ref(), unref(), getStatus(), getExpression(), and getJobName() with Cron. Disposing the instance calls stop(). If the scheduled tick cannot be resolved within about a minute of the boundary, the handler is skipped.

Examples

Start an in-process job with run()

run() schedules the handler in the current process. Repeated calls while it is running do nothing.

const cleanup = new Cron({
  name: "cleanup-temp",
  schedule: "@hourly",
  handler: async () => {
    await clearTempFiles();
  },
});

cleanup.run();

Stop an in-process job with stop()

stop() cancels the active timer and returns the job to idle.

cleanup.stop();

Read the next run with next()

next() parses the schedule and returns its next matching Date, or null if there is no match (for example an impossible date). Pass a date or timestamp to calculate from another starting point.

const everyFiveMinutes = new Cron({
  name: "sync",
  schedule: "*/5 * * * *",
  handler: async () => {
    await syncRemote();
  },
});

everyFiveMinutes.run();
console.log(everyFiveMinutes.next());

Build a schedule with cronJobBuilder()

The builder combines typed fields into a cron expression. Unset fields remain *.

const mondayMorning = new Cron({
  name: "monday-standup",
  schedule: cronJobBuilder((b) =>
    b.minute(number(0)).hour(number(9)).weekday(number(WeekDay.mon)),
  ),
  handler: () => notifyTeam(),
});

Release the process with unref()

unref() lets Bun exit even while the cron timer remains scheduled.

const heartbeat = new Cron({
  name: "heartbeat",
  schedule: "@minutely",
  handler: () => ping(),
});

heartbeat.run();
heartbeat.unref();

Keep the process alive with ref()

After unref(), ref() makes the running timer keep the process alive again.

heartbeat.ref();

Inspect status with getStatus()

Status changes from idle to running after run(), then back to idle after stop().

heartbeat.getStatus(); // "running"
heartbeat.stop();
heartbeat.getStatus(); // "idle"

Expand an alias with getExpression()

getExpression() returns the normalized five-field expression for aliases and returns explicit expressions unchanged.

const daily = new Cron({
  name: "daily",
  schedule: "@daily",
  handler: () => {},
});

daily.getExpression(); // "0 0 * * *"

Read the job name with getJobName()

getJobName() returns the name supplied to the constructor.

daily.getJobName(); // "daily"

Dispose an in-process job

Explicit resource management calls stop() through Symbol.dispose when the block ends.

{
  using report = new Cron({
    name: "report",
    schedule: "@daily",
    handler: () => sendReport(),
  });

  report.run();
}

Register an OS-level job with CronOS.run()

Unlike Cron, CronOS registers a script with the operating system and does not run an in-process handler.

const backup = new CronOS({
  name: "nightly-backup",
  schedule: "@daily",
  path: "./scripts/backup.ts",
});

await backup.run();

Remove an OS-level job with CronOS.stop()

stop() removes the named OS-level registration.

await backup.stop();

Inspect an OS-level job

CronOS shares next(), getExpression(), and getJobName() with in-process jobs; these inspect configuration without registering it.

backup.next();
backup.getExpression(); // "0 0 * * *"
backup.getJobName(); // "nightly-backup"

Reference

Cron options

OptionMeaning
nameJob name
scheduleAlias, cron string, or builder result
handlerAsync or sync function

Cron methods

MethodMeaning
run()Start the schedule
stop()Stop the schedule
next(from?)Next fire time, or null if none
ref() / unref()Keep / release the process
getStatus() / getExpression() / getJobName()Inspect state

Disposing a Cron with Symbol.dispose stops it.

CronOS options

OptionMeaning
nameJob name
scheduleAlias or cron string
pathScript path registered with Bun

CronOS methods

MethodMeaning
run()Register the script with OS-level cron
stop()Remove the OS-level registration
next(from?)Next fire time, or null if none
getExpression() / getJobName()Inspect configuration

CronDistributed options

OptionMeaning
nameJob name (part of the Redis lock key)
scheduleAlias, cron string, or builder result
handlerAsync or sync function
redisRedis client used for tick locks
replicaId?Value stored when this replica acquires the lock
lockTTL?Lock expiry in ms (default 300000)

CronDistributed methods

Same as Cron: run(), stop(), next(from?), ref() / unref(), getStatus(), getExpression(), getJobName(). Disposing with Symbol.dispose stops the job.

Errors

EmptyListError, OutOfBoundError (exported from semola/cron).

On this page