← Blog  ·  October 2026

Go Cron Job Monitoring: Alerts When robfig/cron or gocron Jobs Stop Running

Your nightly cache warm-up job stopped running when you upgraded the Go binary. The old binary was still in the PATH on the server, running the old scheduler with the old configuration. No errors, no alerts, just a cache that was quietly stale for two weeks.

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.


Go-specific scheduler failure modes

Goroutine panic recovery hides errors

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()

Context cancellation silently stops jobs

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()

Binary deployment races

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.


Adding heartbeat monitoring

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.

Simple helper function

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()
}

robfig/cron

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()

gocron v2

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()
Return early on error — don't ping The heartbeat only fires when the job completes successfully. If the job errors and you return before calling 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.

Handling panic recovery explicitly

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()
    },
))

Using robfig/cron's built-in error handling

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.


Kubernetes CronJob — no code changes needed

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

Testing your heartbeat locally

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

Comparison of Go scheduler options

SchedulerPanic recoveryContext supportJob retryHeartbeat location
robfig/cron v3Built-inYesNoAfter successful fn()
gocron v2Built-inYesYes (configurable)After successful task
time.Ticker loopManualVia selectManualAfter successful tick
K8s CronJobProcess exit codeSIGTERMbackoffLimitPostfix && wget

🕐 Know instantly when a Go job stops running

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.

Join the CronWatch waitlist Free cron expression parser

Summary