Files
2026-04-17 23:26:01 +00:00

4.1 KiB
Raw Permalink Blame History

title, impact, impactDescription, tags
title impact impactDescription tags
Schedule Recurring Jobs with the Builtin Cron Scheduler MEDIUM Avoids external schedulers and correctly integrates background tasks with the PocketBase lifecycle cron, scheduling, jobs, go, jsvm, extending

Schedule Recurring Jobs with the Builtin Cron Scheduler

PocketBase includes a cron scheduler that starts automatically with serve. Register jobs before calling app.Start() (Go) or at the top level of a pb_hooks file (JSVM). Each job runs in its own goroutine and receives a standard cron expression.

Incorrect (external timer, blocking hook, replacing system jobs):

// ❌ Using a raw Go timer instead of the app cron  misses lifecycle management
go func() {
    for range time.Tick(2 * time.Minute) {
        log.Println("cleanup")
    }
}()

// ❌ Blocking inside a hook instead of scheduling
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    for {
        time.Sleep(2 * time.Minute)
        log.Println("cleanup") // ❌ blocks the hook and never returns se.Next()
    }
})

// ❌ Removing all cron jobs wipes PocketBase's own log-cleanup and auto-backup jobs
app.Cron().RemoveAll()
// ❌ JSVM: using setTimeout  not supported in the embedded goja engine
setTimeout(() => console.log("run"), 120_000); // ReferenceError

Correct Go:

package main

import (
    "log"

    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
)

func main() {
    app := pocketbase.New()

    // Register before app.Start() so the scheduler knows about the job at launch.
    // MustAdd panics on an invalid cron expression (use Add if you prefer an error return).
    app.Cron().MustAdd("cleanup-drafts", "0 3 * * *", func() {
        // Runs every day at 03:00 UTC in its own goroutine.
        // Use app directly here (not e.App) because this is not inside a hook.
        records, err := app.FindAllRecords("posts",
            core.FilterData("status = 'draft' && created < {:cutoff}"),
        )
        if err != nil {
            app.Logger().Error("cron cleanup-drafts", "err", err)
            return
        }
        for _, r := range records {
            if err := app.Delete(r); err != nil {
                app.Logger().Error("cron delete", "id", r.Id, "err", err)
            }
        }
    })

    // Remove a job by ID (e.g. during a feature flag toggle)
    // app.Cron().Remove("cleanup-drafts")

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Correct JSVM:

// pb_hooks/crons.pb.js
/// <reference path="../pb_data/types.d.ts" />

// Top-level cronAdd() registers the job at hook-load time.
// The handler runs in its own goroutine and has access to $app.
cronAdd("notify-unpublished", "*/30 * * * *", () => {
    // Runs every 30 minutes
    const records = $app.findAllRecords("posts",
        $dbx.hashExp({ status: "draft" })
    );
    console.log(`Found ${records.length} unpublished posts`);
});

// Remove a registered job by ID (useful in tests or feature toggles)
// cronRemove("notify-unpublished");

Cron expression reference:

┌─── minute        (0 - 59)
│  ┌── hour         (0 - 23)
│  │  ┌─ day-of-month (1 - 31)
│  │  │  ┌ month       (1 - 12)
│  │  │  │  ┌ day-of-week  (0 - 6, Sunday = 0)
│  │  │  │  │
*  *  *  *  *

Examples:
  */2 * * * *    every 2 minutes
  0 3 * * *      daily at 03:00
  0 0 * * 0      weekly on Sunday midnight
  @hourly        macro equivalent to 0 * * * *

Key rules:

  • System jobs use the __pb*__ ID prefix (e.g. __pbLogsCleanup__). Never call RemoveAll() or use that prefix for your own jobs.
  • All registered cron jobs are visible and can be manually triggered from Dashboard > Settings > Crons.
  • JSVM handlers have access to $app but not to outer-scope variables (see JSVM scope rule).
  • Go jobs can use app directly (not e.App) because they run outside the hook/transaction context.

Reference: Go Jobs scheduling | JS Jobs scheduling