Step-by-Step Guide to Building a Task Scheduler in Go
Many applications need background jobs like syncing data, cleaning records, or sending emails. To handle this we can build a task scheduler that runs jobs at the right time without blocking the main application. In this blog we will see how to write production-ready scheduler code in Go, and also cover important parts like concurrency, recurrence, and error handling.
High-Level Design
Before we jump into code, let us understand what we are trying to build. A scheduler has three main parts:
Scheduler Service – This is the main manager. It keeps a list of tasks, starts them, stops them, and can reload them if needed.
Task – A task is one job that we want to run again and again. For example, calling an API every 5 minutes.
Runnable Function – This is the actual code that the task will execute. We keep this separate so that the scheduler is generic and can run any kind of job.
The design is simple: the Scheduler Service holds multiple Tasks, and each task has a Runnable Function attached to it. The task uses a ticker and a goroutine to run at the correct interval, while the service makes sure tasks can be managed safely with locks.
Defining the Task Model
Each scheduled job is represented by a Task. A task should know:
its ID (unique name or identifier),
its interval (how often to run),
its status (new, started, or running),
and some internal fields to manage execution (like ticker, cancel function, etc.).
A simple version of the Task struct can look like this:
type taskStatus int
const (
statusNew taskStatus = iota
statusStarted
statusRunning
)
type Task struct {
ID string
Interval time.Duration
Status taskStatus
cancel context.CancelFunc
ticker *time.Ticker
lastRun *time.Time
fn func(ctx context.Context) error
}
Here,
fnis the function that will run when the task executes.tickeris used to schedule execution at fixed intervals.cancelis used to stop the task gracefully.lastRunhelps in calculating the next run time.
This structure keeps the task generic, so it can run any kind of function we provide.
Scheduler Service
The Scheduler Service is the main manager. It keeps track of all tasks, starts them, stops them, and reloads them if needed. It also ensures that multiple tasks can run safely at the same time without conflicts.
A simple Scheduler struct can look like this:
type Scheduler struct {
tasks map[string]*Task
mtx sync.Mutex
}
Here, tasks is a map of active tasks, and mtx is a mutex to protect access to this map.
Some core methods of the service are:
AddTask – Adds a new task to the scheduler and starts it.
StartTask – Runs a single task manually.
Reload – Stops tasks that are no longer needed and starts new ones.
ListTasks – Returns a list of all current tasks and their status.
For example, adding a task could look like this:
func (s *Scheduler) AddTask(t *Task) {
s.mtx.Lock()
defer s.mtx.Unlock()
s.tasks[t.ID] = t
t.start() // start the task immediately
}
The scheduler makes sure that all operations on tasks are thread-safe, so adding, removing, or running tasks does not cause race conditions.
Starting and Stopping Tasks
Each task runs in its own goroutine and uses a ticker to trigger the job at the right interval. We also use a cancellable context to stop the task gracefully when needed.
A simple way to start a task could be:
func (t *Task) start() {
if t.Status >= statusStarted {
return
}
ctx, cancel := context.WithCancel(context.Background())
t.cancel = cancel
t.ticker = time.NewTicker(t.Interval)
t.Status = statusStarted
go func() {
for {
select {
case <-ctx.Done():
t.ticker.Stop()
t.Status = statusNew
return
case <-t.ticker.C:
t.Status = statusRunning
t.fn(ctx) // run the task function
t.Status = statusStarted
}
}
}()
}
To stop a task safely:
func (t *Task) stop() {
if t.Status == statusRunning && t.cancel != nil {
t.cancel()
}
if t.ticker != nil {
t.ticker.Stop()
}
}
We can also trigger a task manually, without waiting for the next ticker tick:
func (t *Task) runNow() {
go t.fn(context.Background())
}
Key points:
Each task has its own goroutine and ticker.
The cancellable context allows the task to stop gracefully.
Using mutexes (or status flags) ensures tasks do not run multiple times concurrently.
Scheduling Logic
A task is usually scheduled to run repeatedly at a fixed interval. For example, every 5 minutes, every hour, or every day. The scheduler needs to calculate when the next run should happen, based on the last run or a defined start time.
Here’s a simplified example:
func (t *Task) nextRun() time.Duration {
if t.lastRun == nil {
return t.Interval // first run uses the defined interval
}
next := t.lastRun.Add(t.Interval)
now := time.Now()
if next.Before(now) {
// if we missed the scheduled time (system was down), fast-forward
missedIntervals := (now.Sub(next) / t.Interval) + 1
next = next.Add(missedIntervals * t.Interval)
}
return next.Sub(now)
}
Explanation:
t.lastRuntracks when the task ran successfully last time.If
lastRunisnil, it’s the first run, so we use the interval directly.If the next scheduled time is in the past (maybe the system was down), we fast-forward to the next valid time.
The scheduler uses this duration to reset the ticker, ensuring that tasks run at the correct intervals even after delays or downtime.
Key points:
Recurrence calculation is critical to ensure tasks are reliable.
The scheduler can handle missed runs automatically.
By tracking
lastRun, we can make the system resilient and predictable.
Concurrency and Safety
Tasks run in separate goroutines, and the scheduler manages multiple tasks at once. To avoid race conditions, we use mutexes (sync.Mutex or sync.RWMutex).
- The scheduler locks the task map when adding, removing, or listing tasks:
s.mtx.Lock()
defer s.mtx.Unlock()
- Each task uses its own mutex when updating status or last run time:
t.mtx.Lock()
t.Status = statusRunning
t.mtx.Unlock()
This ensures tasks do not run twice at the same time and that adding or removing tasks is safe, keeping the scheduler reliable.
Error Handling and Recovery
Tasks can fail or even panic while running. A good scheduler should catch errors, log them, and keep running other tasks without crashing.
- Wrap task execution in a recover block to handle panics:
go func() {
defer func() {
if r := recover(); r != nil {
log.Println("Task panicked:", r)
}
}()
err := t.fn(ctx)
if err != nil {
log.Println("Task error:", err)
}
}()
Log the start time, end time, and duration for monitoring and debugging.
Keep track of last successful run to calculate the next run correctly.
By handling errors and panics, the scheduler becomes resilient and tasks continue running even if one fails.
Extending with Business Logic
The scheduler itself is generic — it does not care what the task actually does. To run real jobs, we attach a Runnable function to each task.
A Runnable is just a Go function with a simple signature:
type Runnable func(ctx context.Context) error
When adding a task, we pass this function:
task := &Task{
ID: "heartbeat",
Interval: 10 * time.Second,
fn: func(ctx context.Context) error {
fmt.Println("Heartbeat at", time.Now())
return nil
},
}
scheduler.AddTask(task)
Now the scheduler can run this function repeatedly at the defined interval.
Key points:
Decoupling task logic from the scheduler makes it reusable.
Any job (API call, cleanup, email, etc.) can be scheduled the same way.
Manual triggers and automatic scheduling work for all task types.
Practical Usage Examples
Once we have the scheduler and task structs ready, using them is simple.
1. Create the Scheduler
scheduler := &Scheduler{
tasks: make(map[string]*Task),
}
2. Add a Task
task1 := &Task{
ID: "heartbeat",
Interval: 5 * time.Second,
fn: func(ctx context.Context) error {
fmt.Println("Heartbeat at", time.Now())
return nil
},
}
scheduler.AddTask(task1)
3. Add Another Task
task2 := &Task{
ID: "cleanup",
Interval: 10 * time.Second,
fn: func(ctx context.Context) error {
fmt.Println("Cleaning up old data at", time.Now())
return nil
},
}
scheduler.AddTask(task2)
4. Manual Trigger
You can run a task immediately without waiting for the next scheduled interval:
task1.runNow()
5. List All Tasks
for _, t := range scheduler.ListTasks() {
fmt.Println(t.ID, "Status:", t.Status)
}
Key Points:
Adding tasks is simple and flexible.
Any function can be scheduled — API calls, cleanup jobs, logs, or emails.
Manual triggers allow testing or one-off execution.
The scheduler takes care of concurrency, next run calculation, and error handling.
Full Working Scheduler Code
package main
import (
"context"
"fmt"
"sync"
"time"
)
// ----------------------- Task -----------------------
type taskStatus int
const (
statusNew taskStatus = iota
statusStarted
statusRunning
)
type Task struct {
ID string
Interval time.Duration
Status taskStatus
cancel context.CancelFunc
ticker *time.Ticker
startTime time.Time
lastRun *time.Time
fn func(ctx context.Context) error
mtx sync.Mutex
}
// Start the task
func (t *Task) start() {
t.mtx.Lock()
if t.Status >= statusStarted {
t.mtx.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
t.cancel = cancel
t.ticker = time.NewTicker(t.Interval)
t.Status = statusStarted
t.mtx.Unlock()
go func() {
for {
select {
case <-ctx.Done():
t.ticker.Stop()
t.mtx.Lock()
t.Status = statusNew
t.mtx.Unlock()
return
case <-t.ticker.C:
t.mtx.Lock()
t.Status = statusRunning
t.mtx.Unlock()
defer func() {
if r := recover(); r != nil {
fmt.Println("Task panicked:", r)
}
}()
err := t.fn(ctx)
if err != nil {
fmt.Println("Task error:", err)
}
now := time.Now()
t.mtx.Lock()
t.lastRun = &now
t.Status = statusStarted
t.mtx.Unlock()
}
}
}()
}
// Stop the task
func (t *Task) stop() {
t.mtx.Lock()
defer t.mtx.Unlock()
if t.cancel != nil {
t.cancel()
}
if t.ticker != nil {
t.ticker.Stop()
}
t.Status = statusNew
}
// Run the task immediately
func (t *Task) runNow() {
go t.fn(context.Background())
}
// ----------------------- Scheduler -----------------------
type Scheduler struct {
tasks map[string]*Task
mtx sync.Mutex
}
// AddTask adds and starts a task
func (s *Scheduler) AddTask(t *Task) {
s.mtx.Lock()
defer s.mtx.Unlock()
s.tasks[t.ID] = t
t.start()
}
// StopTask stops a task by ID
func (s *Scheduler) StopTask(id string) {
s.mtx.Lock()
defer s.mtx.Unlock()
if t, ok := s.tasks[id]; ok {
t.stop()
delete(s.tasks, id)
}
}
// ListTasks returns all tasks
func (s *Scheduler) ListTasks() []*Task {
s.mtx.Lock()
defer s.mtx.Unlock()
tasks := []*Task{}
for _, t := range s.tasks {
tasks = append(tasks, t)
}
return tasks
}
// ----------------------- Example Usage -----------------------
func main() {
scheduler := &Scheduler{
tasks: make(map[string]*Task),
}
// Heartbeat task
task1 := &Task{
ID: "heartbeat",
Interval: 5 * time.Second,
fn: func(ctx context.Context) error {
fmt.Println("Heartbeat at", time.Now())
return nil
},
}
scheduler.AddTask(task1)
// Cleanup task
task2 := &Task{
ID: "cleanup",
Interval: 10 * time.Second,
fn: func(ctx context.Context) error {
fmt.Println("Cleaning up old data at", time.Now())
return nil
},
}
scheduler.AddTask(task2)
// Manual run example
task1.runNow()
// List all tasks
for _, t := range scheduler.ListTasks() {
fmt.Println("Task:", t.ID, "Status:", t.Status)
}
// Keep the program running
select {}
}
Conclusion and Takeaways
Key lessons from this implementation:
Keep the scheduler generic so it can run any job.
Use goroutines and tickers to run tasks at intervals.
Protect shared data with mutexes to avoid race conditions.
Handle errors and panics to keep tasks running reliably.
Track last run times to calculate next runs and recover from downtime.
Decouple task logic from scheduler for flexibility and easier testing.
Note: This blog was created with assistance from ChatGPT

