The scheduler runs your code on a timetable: send a report every morning, prune old rows every hour, retry failed payments every five minutes. You declare every task in Go, in one file, instead of maintaining crontab lines on a server. New to it? Follow the step-by-step guide below the reference.
// routes/schedule.go, passed to vukem.WithSchedule by bootstrap
func Schedule(s *schedule.Schedule, app *vukem.App) {
s.Command("reports:send", "--daily").DailyAt("08:00").WithoutOverlapping()
s.Job(jobs.PruneSessions{}).Hourly()
s.Func("ping", func(ctx context.Context) error { ... }).EveryFiveMinutes()
s.Func("invoices", sendInvoices).Cron("30 9 1 * *").In(saoPaulo)
}Frequencies#
| Method | Expression |
|---|---|
EveryMinute() (default) | * * * * * |
EveryFiveMinutes() | */5 * * * * |
Every(15 * time.Minute) | */15 * * * * (whole minutes dividing an hour or a day) |
Hourly(), HourlyAt(7) | 0 * * * *, 7 * * * * |
Daily(), DailyAt("08:00") | 0 0 * * *, 0 8 * * * |
Weekdays() | keeps the time, Monday–Friday |
WeeklyOn(time.Monday, "09:00") | 0 9 * * 1 |
MonthlyOn(1, "00:00") | 0 0 1 * * |
Cron(expr) | any five-field expression |
Cron expressions support *, values, ranges, steps (*/15, 10-50/20), lists, JAN/MON names and @hourly, @daily, @weekly, @monthly, @yearly. When both day fields are set, a day matches either (Vixie cron). A bad expression panics at boot; the parser is fuzzed.
Time zones and overlap#
SCHEDULE_TIMEZONE(IANA name, defaultUTC) applies to every task;.In(loc)overrides one. The zone database is embedded, so it works on Windows hosts without one.WithoutOverlapping()skips a run while the previous one still holds its lock (released when it ends, or after 24h). Locks useapp.Cache(): setCACHE_STORE=databaseor use Redis when several servers run the schedule.OnOneServer()runs each occurrence on one server: the first scheduler to take the occurrence's lock (task name and minute, kept for an hour) inapp.Cache()runs it, the others skip it. It needs a cache the servers share (CACHE_STORE=databaseor Redis): with the in-process memory cache, or a custom store that does not declarecache.Sharer(see shared stores), the schedule does not build (schedule.ErrLocksNotShared), soserveandschedule:workstop at boot instead of running the task on every server.- Lock keys include the app name (
APP_NAME, lowercased, other characters as-), so several apps can share one Redis or cache table; give each app a uniqueAPP_NAME(or its ownCACHE_PREFIX). The keys changed in v0.25.0: a run in progress during the upgrade deploy is not seen by the new version.
Running it#
serve (the app binary's default command, and vukem serve in development) runs the scheduler in process next to the HTTP server, so a single-process deploy (one Railway, Fly or Render service) still runs your tasks and the built-in hourly session:prune (SESSION_DRIVER=database) and cache:prune (CACHE_STORE=database). The scheduler ticks at the start of every minute, starts only when tasks exist, and stops with the server: on SIGINT/SIGTERM it stops ticking and gives running tasks SHUTDOWN_TIMEOUT to finish, then cancels their ctx before shutdown hooks run. Tasks must honor ctx (pass it to queries and HTTP calls, check it in loops): one that ignores it keeps the process alive until the platform kills it.
To run the schedule in its own process instead, set SCHEDULE_IN_SERVE=false on the web process and run one of:
vukem schedule:work # in process: ticks at the start of every minute
vukem schedule:list # each task and its next runOr one crontab entry calling schedule:run every minute:
* * * * * cd /srv/app && ./app schedule:runSeveral replicas#
Every process that runs the scheduler (each serve replica with the default SCHEDULE_IN_SERVE=true, plus any schedule:work) fires every due task. There is no leader election:
-
A task without
WithoutOverlapping()runs once per replica each time it is due. -
WithoutOverlapping()only skips a run while another one still holds the lock. With the default in-memory cache each replica has its own locks, so it protects nothing across replicas. With a shared store (CACHE_STORE=databaseor Redis) a replica skips the task while another is still running it, but a short task that finishes before the next replica checks can still run again in the same minute. -
OnOneServer()with a shared cache runs the task once per occurrence across replicas.
The built-in prune tasks are idempotent, so running them on every replica is safe. For tasks that must run exactly once per schedule (emails, charges, draws), mark them OnOneServer() with a shared cache, or set SCHEDULE_IN_SERVE=false on the web replicas and run a single schedule:work process.
schedule:work stops the same way (running tasks get SHUTDOWN_TIMEOUT, then their ctx is cancelled); schedule:run passes its own ctx, cancelled on SIGINT/SIGTERM.
Tasks due at the same minute run concurrently; failures and panics are logged and do not stop the others. Command tasks run in the same process through app.Call.
Step by step: a daily cleanup task#
1. Write the task as a function that honours ctx
// PruneDrafts deletes drafts nobody touched in 30 days.
func PruneDrafts(db *database.DB, now func() time.Time) func(context.Context) error {
return func(ctx context.Context) error {
_, err := db.ExecContext(ctx, `DELETE FROM posts WHERE status = 'draft' AND updated_at < ?`, now().AddDate(0, 0, -30))
return err
}
}Passing ctx to the query lets the scheduler cancel it on shutdown.
2. Declare it in routes/schedule.go
func Schedule(s *schedule.Schedule, app *vukem.App) {
db, err := app.DB()
if err != nil {
app.Log.Error("schedule: database", "err", err)
return
}
s.Func("posts:prune-drafts", tasks.PruneDrafts(db, app.Now)).
DailyAt("03:00").
WithoutOverlapping()
}The starter's bootstrap/app.go already passes this function to vukem.WithSchedule. The name (posts:prune-drafts) appears in logs and in schedule:list.
3. Check the timetable
vukem schedule:list
# TASK EXPRESSION NEXT RUN
# posts:prune-drafts 0 3 * * * 2026-10-18T03:00:00Z4. Run it
Nothing else is needed: vukem serve in development and the app binary's serve in production run the scheduler in process. To run every due task once, right now, use vukem schedule:run.
Tasks that are queue jobs or commands#
s.Job(jobs.SendDigest{}).WeeklyOn(time.Monday, "09:00")only dispatches the job; a queue worker runs it, with the queue's retries. Prefer it for work that may fail and should be retried.s.Command("reports:send", "--daily")runs an app command in the same process, exactly asvukem reports:send --dailywould.
Testing#
app.Schedule() returns the schedule your app declared, so a test can check that a task exists and when it runs next:
s, err := app.Schedule()
if err != nil {
t.Fatal(err)
}
for _, task := range s.Tasks() {
if task.Name() == "posts:prune-drafts" {
if got := task.Expression(); got != "0 3 * * *" {
t.Fatalf("expression = %q", got)
}
}
}Test the task's function itself like any other function: call it with a context.Context and a fake clock.
Pitfalls#
- Times are UTC by default.
DailyAt("08:00")is 08:00 UTC unlessSCHEDULE_TIMEZONEor.In(loc)says otherwise. Around daylight-saving changes, a local time can be skipped or repeated. - Long tasks overlap. A five-minute task scheduled
EveryMinute()runs five copies at once withoutWithoutOverlapping(). - Several replicas run every task. Read Several replicas before scaling the web service.
- Ignoring
ctx. A task that does not passctxto its queries and HTTP calls delays shutdown until the platform kills the process. - Heavy work in a task. Tasks run inside the web process by default; dispatch a queue job for anything long or CPU-heavy so requests stay fast.
Security notes#
- Tasks run with the app's full privileges and without a request, so there is no user to authorize: never build a task's behaviour from data a user can edit without validating it.
- Make tasks that move money or send messages idempotent (check "already sent" before sending), so a duplicate run is harmless.
- Failures and panics are logged with the task name; route them to your alerting (for example Telegram logging).