# 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:

1. **Scheduler Service** – This is the main manager. It keeps a list of tasks, starts them, stops them, and can reload them if needed.
    
2. **Task** – A task is one job that we want to run again and again. For example, calling an API every 5 minutes.
    
3. **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:

```go
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,

* `fn` is the function that will run when the task executes.
    
* `ticker` is used to schedule execution at fixed intervals.
    
* `cancel` is used to stop the task gracefully.
    
* `lastRun` helps 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:

```go
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:

1. **AddTask** – Adds a new task to the scheduler and starts it.
    
2. **StartTask** – Runs a single task manually.
    
3. **Reload** – Stops tasks that are no longer needed and starts new ones.
    
4. **ListTasks** – Returns a list of all current tasks and their status.
    

For example, adding a task could look like this:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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.lastRun` tracks when the task ran successfully last time.
    
* If `lastRun` is `nil`, 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:
    

```go
s.mtx.Lock()
defer s.mtx.Unlock()
```

* Each **task** uses its own mutex when updating status or last run time:
    

```go
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
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:

```go
type Runnable func(ctx context.Context) error
```

When adding a task, we pass this function:

```go
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

```go
scheduler := &Scheduler{
    tasks: make(map[string]*Task),
}
```

#### 2\. Add a Task

```go
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

```go
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:

```go
task1.runNow()
```

#### 5\. List All Tasks

```go
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

```go
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**
