Guides

Persistence

Save job definitions to a file, Redis or your own store and load them again on startup. Functions can't be saved, so your code still registers each job's callback.

On this page

Jobs live in memory by default and are gone when the process exits. For many apps that’s fine, because the jobs are defined in code and get added again on every start. Persistence is useful when jobs are created at runtime, for example from an admin page, and their schedules need to outlive the process.

Turning it on

scheduler.ts
import Baker from "cronbake";

const baker = Baker.create({
  persistence: {
    enabled: true,
    filePath: "./data/cronbake.json",
  },
});

await baker.ready();

baker.add({
  name: "nightly-report",
  cron: "@daily",
  start: true,
  callback: sendNightlyReport,
});

filePath defaults to ./cronbake-state.json, and its parent folders are created as needed. See persistence providers to use Redis or your own store.

What happens on restart

  1. While it’s being created, the baker starts reading the saved state. await baker.ready() waits for this to finish.
  2. Each saved job is recreated with its name, schedule, priority, metrics and history. If it was saved as running, it starts straight away.
  3. Restored jobs have a placeholder callback, because functions can’t be saved. It only logs a warning: Restored job 'nightly-report' executed but no callback was provided.
  4. When you call add() with the name of a restored job, your definition replaces it completely, including the schedule.

So a restored job doesn’t do any useful work until your code adds it again with a real callback. For jobs created at runtime, save enough information to rebuild the callback, and map each restored name back to its function during startup.

Things to watch for

Re-adding a job doesn’t keep it running

The replacement is a new job, and it follows the normal start rules. Even if the restored job was running, the new one stays stopped unless you pass start: true, use autoStart, or call bakeAll().

Re-adding a job resets its metrics

await baker.ready();

const previousRuns = baker.getHistory("nightly-report");
await archive(previousRuns);

baker.add({ name: "nightly-report", cron: "@daily", start: true, callback: sendNightlyReport });

Removing a job from your code doesn’t remove it from the state

A job you’ve deleted from your code is still in the saved state, so it comes back on the next start with its placeholder callback. If it was running, it will log a warning on every tick. Remove it once to clear it.

await baker.ready();
baker.remove("old-job-name");

A restore never overwrites a job you’ve added

restoreState() skips any name that already exists, so your definition wins whether you add the job before or after the restore. Awaiting ready() first is still the clearer way to write it, and it’s the only way to read a job’s saved history before replacing it.

When state is saved

The baker saves automatically after add(), remove(), destroy() and destroyAll(). It doesn’t save after bake(), stop(), pause(), resume() or their bulk versions, and it doesn’t save after a run.

This matters because the saved status decides whether a restored job starts. If you add jobs and then call bakeAll(), the last save still says stopped. Call saveState() whenever you want the file to catch up, and always on shutdown.

baker.bakeAll();
await baker.saveState();

// Keep metrics and status fresh while the app runs
baker.add({
  name: "save-cronbake-state",
  cron: "@every_5_minutes",
  persist: false,
  start: true,
  callback: () => baker.saveState(),
});

process.once("SIGTERM", async () => {
  baker.stopAll();
  await baker.saveState();
  process.exit(0);
});

Writes are queued, so overlapping saves can’t interleave. If an automatic save fails, the error goes to logger.warn(). A saveState() you call yourself rejects instead.

What gets saved

Saved Not saved
name, cron, priority callback, onTick, onComplete, onError
Current status immediate, delay, overrunProtection
Metrics and history maxHistory, logger

Restored jobs use the default for anything that isn’t saved, until you add them again.

cronbake.json
{
  "version": 1,
  "timestamp": "2026-11-04T09:30:00.000Z",
  "jobs": [
    {
      "name": "nightly-report",
      "cron": "@daily",
      "status": "running",
      "priority": 0,
      "persist": true,
      "metrics": {
        "totalExecutions": 1,
        "successfulExecutions": 1,
        "failedExecutions": 0,
        "averageExecutionTime": 412
      },
      "history": [
        { "timestamp": "2026-11-04T00:00:00.000Z", "duration": 412, "success": true }
      ]
    }
  ],
  "config": {
    "pollingInterval": 1000,
    "useCalculatedTimeouts": true,
    "maxHistoryEntries": 100
  }
}

config is written for reference but isn’t read back. Scheduler settings always come from the options you pass to Baker.create().

Keeping a job out of the state

Set persist: false on a job and it’s never saved. It defaults to true when persistence is on, and has no effect when persistence is off.

baker.add({
  name: "local-debug",
  cron: "@every_10_seconds",
  persist: false,
  callback: printDebugInfo,
});

Restoring by hand

autoRestore defaults to true. Turn it off to decide when loading happens.

const baker = Baker.create({
  persistence: { enabled: true, autoRestore: false },
});

await connectToEverything();
await baker.restoreState();

When loading fails

If the provider’s load() throws or the saved state is malformed, ready() rejects, and autoStart doesn’t start anything.

The file provider is more forgiving. A missing, unreadable or corrupt file counts as no saved state, so the baker starts empty with no error or warning. If those jobs matter, check that they came back with getJobNames().

What it doesn’t do

Persistence isn’t coordination. Two processes sharing a file or a Redis key overwrite each other’s state, with the last write winning, and neither takes a lock on running a job. Saved state also doesn’t make up for downtime, so ticks that passed while the process was stopped aren’t replayed.

esc

Try overlap, restore or timezone.