GUIDE · GO · FREE

Monitor Go cron jobs: defer ping on exit, stdlib net/http only

A named-return defer inspects the error value at function exit and either GETs the success URL or POSTs the error text to /fail. Add recover() inside the same defer to turn panics into /fail pings. No external packages. Absence detection handles what defer can't: the OOM kill, the crashed binary, the cron entry that vanished. Free, no monitor cap.

The snippets

Create a monitor in the console, set the schedule to match how often the binary runs plus a grace window, copy the ping URL, and wrap your job function.

Named-return defer — stdlib only

Declaring err error as a named return lets the deferred function read the final value after the function body has run, including deferred returns from inner functions:

package main import ( "context" "fmt" "net/http" "os" "strings" ) const pingURL = "https://ping.lastping.dev/<your-monitor-id>" func ping(suffix, body string) { url := pingURL + suffix var ( resp *http.Response err error ) if body != "" { resp, err = http.Post(url, "text/plain", strings.NewReader(body)) } else { resp, err = http.Get(url) } if err == nil { resp.Body.Close() } } func runJob(ctx context.Context) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } if err != nil { msg := err.Error() if len(msg) > 10_000 { msg = msg[:10_000] } ping("/fail", msg) } else { ping("", "") } }() // your job logic return nil } func main() { if err := runJob(context.Background()); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }

In-process scheduler with gocron

When using cron-style in-process scheduling via github.com/go-co-op/gocron, wrap the task body in the same pattern. The external monitor also covers the case where the whole process dies and takes the scheduler with it:

package main import ( "time" "github.com/go-co-op/gocron/v2" ) func main() { s, _ := gocron.NewScheduler() s.NewJob( gocron.CronJob("0 2 * * *", false), gocron.NewTask(func() { if err := runJob(context.Background()); err != nil { // runJob's defer already pinged /fail _ = err } }), ) s.Start() select {} // block forever }

Optional: /start ping for overrun detection

Ping /start before the job runs to arm the hung-job alarm — if no success arrives within the grace window the incident opens even if the process never errors:

func runJob(ctx context.Context) (err error) { rid := fmt.Sprintf("%d", time.Now().UnixMilli()) ping("/start?rid="+rid, "") defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } if err != nil { msg := err.Error() if len(msg) > 10_000 { msg = msg[:10_000] } ping("/fail", msg) } else { ping("?rid="+rid, "") } }() // your job return nil }

Why re-raise (os.Exit) still matters

Returning a non-nil error from runJob and calling os.Exit(1) in main keeps the exit code non-zero so cron, systemd, and container orchestrators still apply their own retry and alerting semantics. The ping adds fast alerting; it doesn't replace the error propagation chain. Also see the LastPing MCP server for creating monitors programmatically from AI agents.


What this catches

The defer reports what it can; the silence reports the rest.

  • A returned error — the /fail ping opens an incident immediately, with the error text stored on the ping event.
  • A panicrecover() inside the defer converts the panic value to an error and sends the /fail ping before the goroutine terminates.
  • An OOM kill or SIGKILL — the process sends no ping; silence after the grace window opens the incident.
  • The binary never ran — missing cron entry, broken deploy, dead host. Absence → incident.
  • A hung run — with the /start ping armed, a job stuck on a blocked channel or a dead connection opens an incident inside the grace window.

Questions people ask

Front-loaded answers — the most important fact first.

  • Do I need a third-party package?

    No. The stdlib net/http package handles both GET and POST requests. You only need an external scheduler like gocron if you want in-process scheduling; the ping call itself is always stdlib.

  • How do I catch panics and send a /fail ping?

    Add recover() inside the same deferred function. If recover() returns a non-nil value, assign it to the named return error. The rest of the defer logic then picks up the non-nil error and POSTs to /fail automatically.

  • What if the Go binary is OOM-killed?

    Deferred functions do not run when the process is killed with SIGKILL. That is exactly why absence-detection monitoring exists: the missing ping itself opens the incident after the grace window, no working code required.

  • Does this work with a Kubernetes CronJob?

    Yes — the Go binary runs as the container entrypoint. The non-zero exit from os.Exit(1) causes the CronJob to mark the run as failed and apply its own failedJobsHistory policy. See also the Kubernetes CronJob monitoring guide.

  • Is this free?

    Yes. LastPing is free and fully hosted with no monitor cap and no paid tier. See how it compares to Healthchecks.io and Cronitor.

FREE · FULLY HOSTED · NO PAID TIER

Deferred functions don't run when the kernel kills your process.

One defer now beats debugging a silent data gap later. First alert wired up in about a minute — and LastPing monitors itself the same way.