Three patterns cover almost every Go cron job: a long-running process using a scheduling library like robfig/cron, a small one-shot binary invoked by the OS's own crontab, or a platform-level scheduler like a Kubernetes CronJob or a Fly Machine on a schedule. Pick based on resolution and deployment shape, not habit. All three have the same blind spot: they tell you a job ran, or that it errored, but not when it stopped running altogether. That third case is the one that costs people the most sleep.
What's the right way to schedule a job in Go?
Use the OS crontab when the job is a short, self-contained task and once-a-minute resolution is fine. Build a small binary, point a crontab line at it, and let the OS own the schedule:
# /etc/cron.d/nightly-report
0 3 * * * deploy /usr/local/bin/nightly-report >> /var/log/nightly-report.log 2>&1
This is the simplest option because the failure surface is small: the binary either exits 0 or it doesn't, and there's no scheduler state living inside your app to get out of sync.
Use robfig/cron when you need sub-minute intervals, the schedule should live in Go code instead of a crontab file, or the job needs to share a connection pool, cache, or config with a process you're already running. robfig/cron is the de facto standard for this in Go: MIT-licensed, dependency-light, and its v3 API is small enough to hold in your head.
package main
import (
"context"
"log"
"github.com/robfig/cron/v3"
)
func main() {
c := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
_, err := c.AddFunc("*/5 * * * *", runReportJob)
if err != nil {
log.Fatalf("invalid schedule: %v", err)
}
c.Start() // non-blocking: runs the scheduler in its own goroutine
// ... rest of your long-running process (HTTP server, etc.) ...
ctx := c.Stop() // stops the scheduler, returns a context
<-ctx.Done() // blocks until any in-flight job finishes
}
func runReportJob() {
// your job logic
}
A few details that matter and aren't obvious from a skim of the docs: New() doesn't parse a seconds field by default, so */5 * * * * means every 5 minutes, not every 5 seconds. Pass cron.WithSeconds() if you need sub-minute granularity. Start() is non-blocking and returns immediately; use Run() instead if the scheduler is the only thing your process does and you want it to block main(). Stop() doesn't kill a job mid-run, it stops scheduling new ones and hands back a context.Context you can wait on with <-ctx.Done() so an in-flight job gets to finish during a graceful shutdown.
Wrap jobs with cron.Recover(logger) as shown above. Without it, a panic inside a scheduled function crashes the entire process, taking every other job (and your HTTP server, if it's in the same binary) down with it.
Use a platform scheduler (Kubernetes CronJob, Fly Machines on a schedule, GitHub Actions scheduled workflows) when the job needs to run in its own isolated container, independent of any long-running service's lifecycle, or when you want the platform, not your app, to own retries and concurrency policy.
What if my service runs on multiple replicas?
This is the gotcha that trips up almost everyone moving from a single server to a horizontally scaled deployment. A robfig/cron scheduler started inside main() runs on every replica that boots that binary. Deploy your service to 5 instances and a job scheduled for 3am fires 5 times at 3am, not once. Each replica has no idea the other four exist.
Three ways to fix it, in order of how much they cost you:
- Run the scheduler in exactly one place. Split cron-running code into its own small deployment with a single replica, separate from your horizontally scaled API. Simplest, and the right default for most teams.
- Use a platform
CronJob. KubernetesCronJoband Fly's scheduled Machines launch exactly one instance per scheduled run, regardless of how many replicas your main service has. The scheduling concern moves out of your app entirely. - Add a distributed lock inside the job body (a short-lived Postgres advisory lock, a Redis
SET key value NX EX seconds, or similar) if you genuinely need the scheduler embedded in every replica for some other reason. Every instance tries to acquire the lock; only the winner runs the job.
Why does a Go cron job fail without any error?
A web handler that panics shows up in your error tracker. A cron job that stops running often doesn't, because there are several failure modes that produce no execution at all rather than a failed one:
- The host didn't come back. A reboot, an OOM kill, a bad deploy that crash-loops the container: in every case, the process that would have started the scheduler never started, so there's nothing to log an error.
- A panic took down the whole scheduler, not just one job. If you skipped
cron.Recover(), one bad run can silently kill every future run in that process. - The schedule itself is wrong.
AddFuncreturns an error on an invalid cron spec, and it's easy to write code that logs that error to stdout, where nobody's watching, and otherwise keeps running as if the job were scheduled. - The deploy broke the crontab. For the OS-crontab pattern, a bad release can overwrite
/etc/cron.d/or change the binary's path without touching the crontab line, and cron keeps invoking a path that no longer does what you think.
Every one of these looks identical from the outside: nothing happened, and nothing complained. Traditional monitoring (error trackers, log alerts, uptime checks on an HTTP endpoint) watches for a bad signal. None of it fires when the signal that should exist simply doesn't.
How do I find out if my Go cron job stopped running?
Add a heartbeat: a ping sent after the job's real work succeeds, to a monitor that expects to hear from you on a schedule and alerts you the moment it doesn't. It's a different mechanism than error handling on purpose: it watches for absence, not failure, which is the only thing that catches a job that never ran at all.
func runReportJob() {
defer func() {
if r := recover(); r != nil {
log.Printf("report job panicked: %v", r)
return // no ping, a flatline is exactly the signal you want here
}
}()
if err := generateNightlyReport(); err != nil {
log.Printf("report job failed: %v", err)
return // same: don't ping on failure
}
resp, err := http.Post("https://ingest.canaryhub.ai/ping/YOUR_TOKEN", "", nil)
if err != nil {
log.Printf("heartbeat ping failed: %v", err)
return
}
resp.Body.Close()
}
The whole integration is that one http.Post call at the true end of the function, guarded so an early return (from a panic, an error, or a recover) skips it. CanaryHub's heartbeat monitors work on the same GET|POST https://ingest.canaryhub.ai/ping/<token> endpoint used for crontab-based jobs; the token in the URL is the auth, so there's no SDK or API key to wire up. Set the monitor's expected interval to match your schedule plus a grace period, and the monitor goes from green to alerting the first time a ping is late. It doesn't wait for a second missed run, because the grace period is already the buffer.
This pairs with, rather than replaces, the panic recovery and error logging you already have. Error logs tell you why a run failed. A heartbeat tells you a run didn't happen in the first place, which is the case your logs can't cover because there was nothing to log.
A working baseline
Putting the pieces together: a robfig/cron scheduler with panic recovery, running in a dedicated single-replica deployment (so it isn't duplicated across instances), pinging a heartbeat monitor only after each job genuinely succeeds. That combination catches the three failure classes that matter for a scheduled job in Go: the job errors, the job runs twice because of a scaling mistake, and the job stops running without a trace. All with about ten lines of code total beyond the job logic itself.
