Go schedulers like robfig/cron and gocron are elegant and reliable. They're also completely silent when something goes wrong. Panic recovery, goroutine death, binary swap, context cancellation — none of these produce an alert. The job just stops.
This guide covers the specific failure patterns for Go schedulers and how to add heartbeat monitoring in under 10 lines of code.
Both robfig/cron and gocron run each job in its own goroutine and recover from panics by default. This is the right behavior — one bad job shouldn't crash the scheduler. But it means a panicking job logs an error and then silently stops running until the next scheduled tick, which may never come if the panic was caused by a persistent condition (nil pointer, database down, invalid config).
c := cron.New()
c.AddFunc("@daily", func() {
result := processNightlyBatch() // panics when DB connection pool is exhausted
cache.Warm(result)
// robfig/cron recovers the panic, logs it, and schedules the next run
// But if the DB stays down, every run panics silently forever
})
c.Start()
gocron v2 and newer versions of robfig/cron accept a context. When the context is cancelled — graceful shutdown, k8s SIGTERM, a parent context timeout — the scheduler stops. If the shutdown is unclean, jobs scheduled to run during the shutdown window are silently skipped. The process exits 0 (clean shutdown), and nobody knows the 3am job didn't run.
ctx, cancel := context.WithCancel(context.Background())
s, _ := gocron.NewScheduler()
s.NewJob(
gocron.CronJob("0 3 * * *", false),
gocron.NewTask(func() {
runNightlyCleanup()
}),
)
s.Start()
// Signal handling — clean shutdown
<-sigChan
cancel() // scheduler stops; if it's 2:59am, the 3am job is silently skipped
s.Shutdown()
When you deploy a new binary, there's a window where the old scheduler is stopping and the new one is starting. Any job scheduled to run in that window is silently skipped. For low-frequency jobs (daily, weekly), a single bad deploy can mean missing a run entirely, with no indication in any log.
The dead man's switch pattern is well-suited to Go: ping a URL on successful completion, get an alert if the URL doesn't receive a ping within the expected window.
package main
import (
"log"
"net/http"
"time"
)
// heartbeat pings CronWatch on successful job completion.
// It never returns an error — a monitoring outage must never break the job.
func heartbeat(token string) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://cronwatch.lior-seats.io/ping/" + token)
if err != nil {
log.Printf("cronwatch heartbeat failed: %v", err)
return
}
resp.Body.Close()
}
c := cron.New()
c.AddFunc("@daily", func() {
if err := processNightlyBatch(); err != nil {
log.Printf("nightly batch failed: %v", err)
return // don't ping — missed heartbeat triggers the alert
}
heartbeat(os.Getenv("CRONWATCH_NIGHTLY_TOKEN"))
})
c.Start()
s, _ := gocron.NewScheduler()
s.NewJob(
gocron.CronJob("0 3 * * *", false),
gocron.NewTask(func() {
if err := runNightlyCleanup(); err != nil {
log.Printf("cleanup failed: %v", err)
return
}
heartbeat(os.Getenv("CRONWATCH_CLEANUP_TOKEN"))
}),
)
s.Start()
heartbeat(), CronWatch detects the missed ping and alerts you. This is the correct behavior — you want to know the job didn't succeed, not just that it started.
Both major schedulers recover panics internally, but if you want to capture them for your own logging or alerting, wrap the job in your own recover:
func withPanicAlert(token string, fn func() error) func() {
return func() {
defer func() {
if r := recover(); r != nil {
log.Printf("job panicked: %v", r)
// Don't call heartbeat — missed ping will trigger the alert
}
}()
if err := fn(); err != nil {
log.Printf("job failed: %v", err)
return
}
heartbeat(token)
}
}
// Usage:
c.AddFunc("@daily", withPanicAlert(
os.Getenv("CRONWATCH_TOKEN"),
func() error {
return processNightlyBatch()
},
))
robfig/cron v3 has a Chain mechanism for middleware-style job wrapping. You can build a heartbeat chain that applies to all jobs:
package main
import (
"github.com/robfig/cron/v3"
"log"
"net/http"
"time"
)
// HeartbeatChain wraps a cron.Job and pings a URL on successful completion.
type HeartbeatChain struct {
token string
}
func (h HeartbeatChain) Wrap(j cron.Job) cron.Job {
return cron.FuncJob(func() {
// Run the wrapped job; recover panics
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("job panicked: %v", r)
}
}()
j.Run()
}()
// Ping after successful completion (panic skips this)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://cronwatch.lior-seats.io/ping/" + h.token)
if err == nil {
resp.Body.Close()
}
})
}
// Usage: apply per-job
c := cron.New(cron.WithChain(
HeartbeatChain{token: os.Getenv("CRONWATCH_TOKEN")},
))
Note: with this approach, all jobs added to this cron instance share the same token. For separate alerts per job, create separate cron instances or use the per-job wrapper pattern above.
For Go binaries running as Kubernetes CronJobs, you can add the heartbeat ping as a postfix shell command without modifying the Go code:
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-batch
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: job
image: myapp:latest
command: ["/bin/sh", "-c"]
args:
- |
/app/nightly-batch && \
wget -qO- https://cronwatch.lior-seats.io/ping/$CRONWATCH_TOKEN
env:
- name: CRONWATCH_TOKEN
valueFrom:
secretKeyRef:
name: cronwatch-secrets
key: nightly-batch-token
restartPolicy: Never
Before deploying, verify the heartbeat call works and that a missing ping would trigger an alert:
# Test that the ping succeeds
curl -i https://cronwatch.lior-seats.io/ping/YOUR_TOKEN
# Expected: HTTP 200
# Simulate a missed run: don't ping for longer than the grace period
# CronWatch should send you an alert
| Scheduler | Panic recovery | Context support | Job retry | Heartbeat location |
|---|---|---|---|---|
| robfig/cron v3 | Built-in | Yes | No | After successful fn() |
| gocron v2 | Built-in | Yes | Yes (configurable) | After successful task |
| time.Ticker loop | Manual | Via select | Manual | After successful tick |
| K8s CronJob | Process exit code | SIGTERM | backoffLimit | Postfix && wget |
CronWatch monitors your scheduled jobs with a dead man's switch. Add a single heartbeat(token) call after your job logic — get an alert if it doesn't fire on schedule. Works with robfig/cron, gocron, time.Ticker loops, and Kubernetes CronJobs.
&& wget pattern adds monitoring without any Go code changes