Scheduled tasks in Go: a practical guide for developers
Running scheduled tasks is a core requirement in most production services—whether you're processing reports, syncing data, or cleaning up stale records. We've all felt the tension between building something quick during development and realizing in production that we need a more robust, scalable approach. This guide covers scheduled tasks in Go, from simple intervals to distributed systems handling.
Understanding when to schedule tasks
Before diving into implementation, let's be clear about what we're solving. Scheduled tasks in Go cover scenarios like:
- Running a cleanup job every hour to delete expired sessions
- Sending digest emails every morning at 9 AM
- Syncing inventory from an external API every 15 minutes
- Generating daily reports and uploading them to S3
The challenge isn't always the scheduling itself—it's ensuring tasks run reliably, don't pile up if one execution takes too long, and work correctly when you have multiple service instances. We'll explore solutions that scale from a single binary to a distributed cluster.
time.Ticker for simple intervals
For straightforward, repeating intervals, Go's standard library gives us time.Ticker. This is your starting point for most in-process scheduling needs.
package main
import (
"fmt"
"time"
)
func runCleanupJob(ctx context.Context) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("Running cleanup job")
if err := cleanupExpiredSessions(); err != nil {
fmt.Printf("Cleanup failed: %v\n", err)
}
case <-ctx.Done():
fmt.Println("Cleanup job stopped")
return
}
}
}
This pattern is solid for services where you need predictable intervals. The ticker fires at regular intervals, and we handle context cancellation for graceful shutdown. However, there are limitations: it doesn't support cron expressions like "0 9 * * *" (9 AM daily), and it doesn't automatically prevent overlapping executions if your job takes longer than the interval.
In a real scenario, we typically run this in a goroutine at service startup and ensure proper cleanup on shutdown. For example, a trade reconciliation service might use a ticker every 30 minutes to verify all fills match the exchange records. If reconciliation takes longer than 30 minutes occasionally, the next cycle waits—there's no concurrent execution, which is good. But if it consistently runs over, you'll fall behind.
robfig/cron for cron expressions
When you need more sophisticated scheduling—specific times of day, days of the week, or complex patterns—the robfig/cron library is the Go standard. It supports standard cron syntax and integrates cleanly with your application lifecycle.
package main
import (
"fmt"
"github.com/robfig/cron/v3"
)
func setupScheduler() (*cron.Cron, error) {
c := cron.New()
// Run daily report at 9 AM
_, err := c.AddFunc("0 9 * * *", func() {
fmt.Println("Generating daily report")
if err := generateDailyReport(); err != nil {
fmt.Printf("Report generation failed: %v\n", err)
}
})
if err != nil {
return nil, err
}
// Run data sync every 15 minutes
_, err = c.AddFunc("*/15 * * * *", func() {
fmt.Println("Syncing data")
syncInventoryFromAPI()
})
if err != nil {
return nil, err
}
// Run cleanup every Sunday at midnight
_, err = c.AddFunc("0 0 * * 0", func() {
fmt.Println("Weekly cleanup")
cleanupArchivedData()
})
if err != nil {
return nil, err
}
c.Start()
return c, nil
}
func main() {
scheduler, err := setupScheduler()
if err != nil {
panic(err)
}
defer scheduler.Stop()
// Service runs indefinitely
select {}
}
The robfig/cron library is battle-tested in production and handles the parsing and execution logic. Each job gets a unique ID that you can use to remove it later. The library ensures that if a job takes longer than the scheduled interval, the next execution still waits for the current one to complete—preventing overlapping runs.
In practice, we've used this for e-commerce order processing: reconciling orders from multiple payment gateways at 2 AM each day, right when traffic is lowest. The cron expression is readable in code, making it obvious what the schedule is. However, in a production service with multiple replicas, this creates a problem: every instance runs the same job at the same time.
Leader election for multiple instances
When you deploy multiple instances of a service behind a load balancer, you don't want every instance running the same scheduled task. If you have three API servers and each runs a report generation job, you'll create three reports instead of one. This wastes resources and can corrupt data if jobs are supposed to update a shared resource.
The solution is leader election: exactly one instance claims responsibility for running the scheduled task. If that instance crashes, another takes over. Most teams use a distributed coordination service for this—Redis, etcd, or your cloud provider's equivalent.
package main
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/robfig/cron/v3"
)
type LeaderScheduler struct {
redis *redis.Client
cron *cron.Cron
leaseKey string
leaderID string
ctx context.Context
cancel context.CancelFunc
}
func NewLeaderScheduler(redisClient *redis.Client) *LeaderScheduler {
return &LeaderScheduler{
redis: redisClient,
leaderID: uuid.New().String(),
leaseKey: "scheduled-tasks:leader",
cron: cron.New(),
}
}
func (ls *LeaderScheduler) AddJob(spec string, fn func()) error {
// Wrap the job to check leadership
wrappedJob := func() {
if ls.isLeader() {
fn()
} else {
fmt.Printf("Not leader, skipping job\n")
}
}
_, err := ls.cron.AddFunc(spec, wrappedJob)
return err
}
func (ls *LeaderScheduler) isLeader() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Try to acquire the lease with 30-second TTL
ok, err := ls.redis.SetNX(ctx, ls.leaseKey, ls.leaderID, 30*time.Second).Result()
if err != nil {
return false
}
return ok
}
func (ls *LeaderScheduler) renewLeadership(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
renCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := ls.redis.Get(renCtx, ls.leaseKey).Err()
cancel()
if err != redis.Nil {
// We hold the lease, renew it
ls.redis.Expire(context.Background(), ls.leaseKey, 30*time.Second)
}
case <-ctx.Done():
return
}
}
}
func (ls *LeaderScheduler) Start(ctx context.Context) {
ls.ctx, ls.cancel = context.WithCancel(ctx)
ls.cron.Start()
go ls.renewLeadership(ls.ctx)
}
func (ls *LeaderScheduler) Stop() {
ls.cancel()
ls.cron.Stop()
}
This approach uses Redis as a distributed lock. Each instance tries to acquire a lease key with a 30-second TTL. The leader renews the lease every 10 seconds. If the leader crashes, the lease expires and another instance acquires it within 30 seconds. All instances still run the cron schedule, but only the leader actually executes job logic.
In a ride-hailing platform, we used this pattern to ensure only one instance uploaded trip summaries to the data warehouse every 6 hours. Without leader election, we'd have overwrites or duplicates affecting analytics downstream. The trade-off is complexity: you now depend on Redis availability, and there's a small window where two instances might act as leader (during lease transition). For critical jobs, this risk is often unacceptable.
Prefer external schedulers for critical jobs
Here's the hard truth: scheduling within your application has limits. If your service crashes, scheduled tasks stop. If you need to modify the schedule, you redeploy. If you want observability into job execution across all instances, you need to instrument the code. For truly critical jobs—payment reconciliation, billing runs, compliance audits—external schedulers are the answer.
Services like AWS Lambda (for time-triggered functions), cloud-native job schedulers (Kubernetes CronJobs), or dedicated job queues (Temporal, Airflow, or even simpler tools like AWS EventBridge) handle the operational burden. Your application becomes a consumer of work rather than its orchestrator.
// Example: Your API receives a request to run a critical job
func (s *Service) HandleJobRequest(ctx context.Context, jobType string) error {
// Job logic here—doesn't know or care when it was scheduled
switch jobType {
case "daily-reconciliation":
return s.runDailyReconciliation(ctx)
case "monthly-billing":
return s.runMonthlyBilling(ctx)
default:
return fmt.Errorf("unknown job type")
}
}
An external scheduler (Lambda, EventBridge, or Kubernetes CronJob) periodically makes an HTTP request or publishes a message to a queue, triggering your job. This separation gives you retry logic at the scheduler level, audit trails, and easy rescheduling. For the CRM system we built, billing jobs are now orchestrated by AWS EventBridge with built-in retry and DLQ support. Our API doesn't own the schedule; it just reliably executes what it's asked to do.
Common mistakes
- Not handling context cancellation: Scheduled tasks should respect context.Done() for graceful shutdown. If you ignore it, you risk hanging goroutines during deployments.
- Assuming jobs won't overlap: If a job takes longer than its interval, you need explicit logic to prevent concurrent runs. The cron library handles this, but basic ticker-based approaches don't.
- Running critical jobs in-process without backup: A network blip or crash silently breaks your schedule. Use external schedulers for jobs where missing an execution is unacceptable.
- Not logging job execution: When a scheduled task fails silently, debugging is a nightmare. Always log start, completion, and errors with timestamps and durations.
- Ignoring timezone handling: Cron specs are in UTC by default. If you need "9 AM in the user's timezone," that's a different problem requiring logic outside cron.
- Forgetting to stop the scheduler on shutdown: Leaving a cron instance running can cause resource leaks. Always defer Stop() in your startup code or use a proper lifecycle manager.
Key takeaways
- Use
time.Tickerfor simple fixed intervals; it's built-in and sufficient for most cases. - Use
robfig/cronwhen you need cron expressions; it's the standard and handles edge cases like job overlap prevention. - Implement leader election with Redis or similar when you have multiple instances; prevent duplicate job execution with a distributed lock.
- For critical jobs, move scheduling outside your application to external services like Lambda, EventBridge, or Kubernetes CronJobs; your API becomes a job executor, not an orchestrator.
- Always log execution, handle context cancellation, and ensure graceful shutdown; these practices catch issues before they hit production.






Comments
Loading comments…