Go Scheduled Task Monitoring Guide
Go is popular for backend services with scheduled tasks. A crashed binary exits without notice, a hung process stays stuck forever. Here's how to monitor them.

Go Scheduled Task Monitoring Guide
Go is increasingly popular for building backend services and command-line tools, many of which rely on scheduled tasks. Whether you run Go binaries via system cron or use libraries like robfig/cron for in-process scheduling, these jobs share a common problem: they fail silently. A crashed binary exits without notice, and a hung process stays stuck forever.
This guide covers monitoring patterns for Go scheduled tasks, from simple cron scripts to sophisticated job schedulers. For foundational concepts, check out our complete guide to cron job monitoring.
Go Scheduling Options
Go applications handle scheduled tasks in several ways.
System Cron with Go Binaries
The simplest approach: compile a Go program and schedule it with system cron. The binary runs, does its work, and exits.
# crontab
0 0 * * * /usr/local/bin/daily-reportrobfig/cron Library
The most popular Go scheduling library. Jobs run within your Go process on a cron schedule.
import "github.com/robfig/cron/v3"
c := cron.New()
c.AddFunc("0 0 * * *", dailyJob)
c.Start()go-co-op/gocron
A newer library with a fluent API:
import "github.com/go-co-op/gocron"
s := gocron.NewScheduler(time.UTC)
s.Every(1).Day().At("00:00").Do(dailyJob)
s.StartBlocking()Built-in time.Ticker
For simple periodic tasks within a long-running application:
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
hourlyJob()
}Monitoring System Cron Go Programs
Let's start with the most common pattern: a Go binary scheduled via system cron.
Basic monitored program:
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
)
const monitorURL = "https://ping.example.com/abc123"
var client = &http.Client{
Timeout: 10 * time.Second,
}
func ping(endpoint string) {
url := monitorURL + endpoint
resp, err := client.Get(url)
if err != nil {
log.Printf("Monitor ping failed: %v", err)
return
}
defer resp.Body.Close()
}
func main() {
// Signal start
ping("/start")
if err := processData(); err != nil {
// Signal failure
ping("/fail")
log.Fatal(err)
}
// Signal success
ping("")
fmt.Println("Job completed successfully")
}
func processData() error {
// Your job logic here
fmt.Println("Processing data...")
return nil
}Key points:
- Create a shared HTTP client with a timeout
- Signal start before any processing
- Signal failure before calling
log.Fatal(which exits immediately) - Signal success only after all work completes
Monitoring robfig/cron Jobs
For in-process scheduling with robfig/cron, wrap your job functions with monitoring.
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/robfig/cron/v3"
)
var client = &http.Client{
Timeout: 10 * time.Second,
}
var monitors = map[string]string{
"daily-report": os.Getenv("MONITOR_DAILY_REPORT"),
"hourly-sync": os.Getenv("MONITOR_HOURLY_SYNC"),
"cleanup": os.Getenv("MONITOR_CLEANUP"),
}
func ping(monitorURL, endpoint string) {
if monitorURL == "" {
return
}
resp, err := client.Get(monitorURL + endpoint)
if err != nil {
log.Printf("Monitor ping failed: %v", err)
return
}
defer resp.Body.Close()
}
func monitoredJob(name string, job func() error) func() {
return func() {
url := monitors[name]
ping(url, "/start")
if err := job(); err != nil {
log.Printf("Job %s failed: %v", name, err)
ping(url, "/fail")
return
}
ping(url, "")
}
}
func dailyReport() error {
log.Println("Generating daily report...")
// Report generation logic
return nil
}
func hourlySync() error {
log.Println("Syncing data...")
// Sync logic
return nil
}
func cleanup() error {
log.Println("Running cleanup...")
// Cleanup logic
return nil
}
func main() {
c := cron.New()
c.AddFunc("0 0 * * *", monitoredJob("daily-report", dailyReport))
c.AddFunc("0 * * * *", monitoredJob("hourly-sync", hourlySync))
c.AddFunc("0 3 * * *", monitoredJob("cleanup", cleanup))
c.Start()
// Block forever (or until signal)
select {}
}Creating a Reusable Wrapper
For cleaner code, create a reusable monitoring wrapper.
package monitor
import (
"fmt"
"net/http"
"net/url"
"os"
"time"
)
// Monitor handles job monitoring pings
type Monitor struct {
BaseURL string
Client *http.Client
}
// New creates a Monitor from an environment variable
func New(envVar string) *Monitor {
return &Monitor{
BaseURL: os.Getenv(envVar),
Client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// NewWithURL creates a Monitor with a direct URL
func NewWithURL(baseURL string) *Monitor {
return &Monitor{
BaseURL: baseURL,
Client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Ping sends a signal to the monitor
func (m *Monitor) Ping(endpoint string) {
if m.BaseURL == "" {
return
}
resp, err := m.Client.Get(m.BaseURL + endpoint)
if err != nil {
return // Silently ignore monitoring failures
}
defer resp.Body.Close()
}
// PingWithError sends a failure signal with error details
func (m *Monitor) PingWithError(err error) {
if m.BaseURL == "" {
return
}
u, parseErr := url.Parse(m.BaseURL + "/fail")
if parseErr != nil {
return
}
q := u.Query()
if err != nil {
msg := err.Error()
if len(msg) > 100 {
msg = msg[:100]
}
q.Set("error", msg)
}
u.RawQuery = q.Encode()
resp, httpErr := m.Client.Get(u.String())
if httpErr != nil {
return
}
defer resp.Body.Close()
}
// Start signals job start
func (m *Monitor) Start() {
m.Ping("/start")
}
// Success signals successful completion
func (m *Monitor) Success() {
m.Ping("")
}
// Fail signals job failure
func (m *Monitor) Fail(err error) {
m.PingWithError(err)
}
// Wrap creates a monitored version of a function
func (m *Monitor) Wrap(fn func() error) func() {
return func() {
m.Start()
if err := fn(); err != nil {
m.Fail(err)
return
}
m.Success()
}
}
// Run executes a function with monitoring
func (m *Monitor) Run(fn func() error) error {
m.Start()
err := fn()
if err != nil {
m.Fail(err)
return err
}
m.Success()
return nil
}Using the wrapper:
package main
import (
"log"
"github.com/robfig/cron/v3"
"myapp/monitor"
)
func main() {
reportMonitor := monitor.New("MONITOR_DAILY_REPORT")
syncMonitor := monitor.New("MONITOR_HOURLY_SYNC")
c := cron.New()
c.AddFunc("0 0 * * *", reportMonitor.Wrap(dailyReport))
c.AddFunc("0 * * * *", syncMonitor.Wrap(hourlySync))
c.Start()
select {}
}
func dailyReport() error {
log.Println("Generating report...")
return nil
}
func hourlySync() error {
log.Println("Syncing data...")
return nil
}For standalone binaries:
package main
import (
"fmt"
"log"
"os"
"myapp/monitor"
)
func main() {
m := monitor.New("MONITOR_DAILY_JOB")
err := m.Run(func() error {
return processData()
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Done!")
}
func processData() error {
// Your job logic
return nil
}HTTP Client Best Practices
Go's default HTTP client has no timeout, which can cause jobs to hang. Always configure a client properly.
package main
import (
"log"
"net/http"
"time"
)
// Create a client with sensible defaults
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: true,
},
}
func ping(url string) {
resp, err := client.Get(url)
if err != nil {
log.Printf("Monitor ping failed: %v", err)
return
}
defer resp.Body.Close()
// Optionally check status code
if resp.StatusCode >= 400 {
log.Printf("Monitor returned status %d", resp.StatusCode)
}
}Passing Job Metadata
Send additional context with your pings for better observability.
package main
import (
"fmt"
"net/http"
"net/url"
"time"
)
var client = &http.Client{Timeout: 10 * time.Second}
func pingWithData(baseURL string, data map[string]string) {
u, err := url.Parse(baseURL)
if err != nil {
return
}
q := u.Query()
for k, v := range data {
q.Set(k, v)
}
u.RawQuery = q.Encode()
resp, err := client.Get(u.String())
if err != nil {
return
}
defer resp.Body.Close()
}
func main() {
monitorURL := "https://ping.example.com/abc123"
// Signal start
pingWithData(monitorURL+"/start", nil)
// Do work
processed, err := processRecords()
if err != nil {
pingWithData(monitorURL+"/fail", map[string]string{
"error": err.Error(),
})
return
}
// Signal success with metadata
pingWithData(monitorURL, map[string]string{
"processed": fmt.Sprintf("%d", processed),
"duration": "45s",
})
}
func processRecords() (int, error) {
// Process logic
return 150, nil
}Go-Specific Considerations
Graceful Shutdown Handling
Long-running schedulers should handle shutdown signals gracefully.
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/robfig/cron/v3"
"myapp/monitor"
)
func main() {
c := cron.New()
reportMonitor := monitor.New("MONITOR_DAILY_REPORT")
c.AddFunc("0 0 * * *", reportMonitor.Wrap(dailyReport))
c.Start()
// Handle shutdown signals
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
// Stop accepting new jobs
ctx := c.Stop()
// Wait for running jobs to complete (with timeout)
select {
case <-ctx.Done():
log.Println("All jobs completed")
case <-time.After(30 * time.Second):
log.Println("Timeout waiting for jobs")
}
}
func dailyReport() error {
// Report logic
return nil
}Context Cancellation
For jobs that support cancellation, use context.
package main
import (
"context"
"log"
"net/http"
"time"
)
type MonitoredJob struct {
MonitorURL string
Client *http.Client
}
func (m *MonitoredJob) Run(ctx context.Context, fn func(context.Context) error) error {
m.ping("/start")
err := fn(ctx)
if ctx.Err() != nil {
// Job was cancelled
m.ping("/fail?error=cancelled")
return ctx.Err()
}
if err != nil {
m.ping("/fail")
return err
}
m.ping("")
return nil
}
func (m *MonitoredJob) ping(endpoint string) {
if m.MonitorURL == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", m.MonitorURL+endpoint, nil)
resp, err := m.Client.Do(req)
if err != nil {
log.Printf("Ping failed: %v", err)
return
}
defer resp.Body.Close()
}Concurrent Job Safety
When running multiple jobs concurrently, ensure your monitoring doesn't introduce race conditions.
package main
import (
"sync"
"time"
"github.com/robfig/cron/v3"
"myapp/monitor"
)
func main() {
c := cron.New(cron.WithChain(
cron.Recover(cron.DefaultLogger), // Recover from panics
))
// Each job gets its own monitor instance
// No shared state between jobs
c.AddFunc("0 * * * *", func() {
m := monitor.New("MONITOR_JOB_A")
m.Run(jobA)
})
c.AddFunc("0 * * * *", func() {
m := monitor.New("MONITOR_JOB_B")
m.Run(jobB)
})
c.Start()
select {}
}
func jobA() error { return nil }
func jobB() error { return nil }Binary Deployment Considerations
Go compiles to static binaries, which simplifies deployment but requires different approaches than interpreted languages.
Build with version info:
package main
import (
"fmt"
"runtime"
)
var (
version = "dev"
buildTime = "unknown"
)
func main() {
fmt.Printf("Version: %s, Built: %s, Go: %s\n",
version, buildTime, runtime.Version())
// ... rest of main
}Build command:
go build -ldflags "-X main.version=1.2.3 -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o daily-jobCrontab entry:
0 0 * * * MONITOR_URL=https://ping.example.com/abc123 /usr/local/bin/daily-jobComplete Example: Production-Ready Cron Service
Here's a complete example combining all best practices.
package main
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/robfig/cron/v3"
)
// Monitor handles job monitoring
type Monitor struct {
url string
client *http.Client
}
func NewMonitor(envVar string) *Monitor {
return &Monitor{
url: os.Getenv(envVar),
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (m *Monitor) ping(endpoint string, params map[string]string) {
if m.url == "" {
return
}
u, err := url.Parse(m.url + endpoint)
if err != nil {
return
}
if params != nil {
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
u.RawQuery = q.Encode()
}
resp, err := m.client.Get(u.String())
if err != nil {
log.Printf("Monitor ping failed: %v", err)
return
}
defer resp.Body.Close()
}
func (m *Monitor) Wrap(fn func() error) func() {
return func() {
start := time.Now()
m.ping("/start", nil)
err := fn()
duration := time.Since(start)
if err != nil {
errMsg := err.Error()
if len(errMsg) > 100 {
errMsg = errMsg[:100]
}
m.ping("/fail", map[string]string{
"error": errMsg,
"duration": duration.String(),
})
log.Printf("Job failed after %v: %v", duration, err)
return
}
m.ping("", map[string]string{
"duration": duration.String(),
})
log.Printf("Job completed in %v", duration)
}
}
func dailyReport() error {
log.Println("Generating daily report...")
time.Sleep(2 * time.Second) // Simulate work
return nil
}
func hourlySync() error {
log.Println("Syncing data...")
time.Sleep(1 * time.Second) // Simulate work
return nil
}
func main() {
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Println("Starting scheduler...")
c := cron.New(
cron.WithLogger(cron.VerbosePrintfLogger(log.Default())),
cron.WithChain(cron.Recover(cron.DefaultLogger)),
)
// Register jobs with monitoring
reportMonitor := NewMonitor("MONITOR_DAILY_REPORT")
syncMonitor := NewMonitor("MONITOR_HOURLY_SYNC")
c.AddFunc("0 0 * * *", reportMonitor.Wrap(dailyReport))
c.AddFunc("0 * * * *", syncMonitor.Wrap(hourlySync))
c.Start()
log.Println("Scheduler started")
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
ctx := c.Stop()
select {
case <-ctx.Done():
log.Println("All jobs completed")
case <-time.After(30 * time.Second):
log.Println("Shutdown timeout")
}
}Conclusion
Go's simplicity and performance make it excellent for scheduled tasks, but the same traits that make Go binaries reliable also make failures invisible. A crashed binary exits silently; a hung goroutine blocks forever without complaint.
Adding monitoring to Go scheduled tasks is straightforward. Create an HTTP client with appropriate timeouts, signal when jobs start and complete, and handle errors explicitly. The reusable monitor pattern shown here works across standalone binaries and in-process schedulers.
Start with your critical jobs: those that process payments, sync important data, or generate reports. Once those are monitored, expand coverage to maintenance tasks. The few lines of monitoring code will save hours of debugging when jobs inevitably fail.
If you are evaluating monitoring tools for your Go applications, see our cron monitoring pricing comparison and best cron monitoring tools guides.
Ready to monitor your Go scheduled tasks? Cron Crew works with any Go scheduling approach. Create a monitor, set your environment variable, and add a few lines of code for complete visibility into your background jobs.