How to Build a Job Scheduler in Go

Web
Build a Go job scheduler using time.NewTicker to run tasks at fixed intervals without external libraries.

The timing problem in background jobs

You wrote a background worker that fetches updates every five minutes. You wrapped the logic in a for loop with time.Sleep. It works in development. In production, the external service occasionally takes six minutes to respond. Your loop waits for the response, then sleeps for five minutes. The effective interval becomes eleven minutes. You move the sleep to the top of the loop. Now the interval is five minutes plus the job duration. The timing drifts unpredictably relative to the wall clock. You need a mechanism that fires at a fixed rate, or one that guarantees a precise gap between executions. Go's standard library provides time.Ticker for fixed-rate execution and time.Sleep for fixed-gap execution. The standard library handles the timing mechanics so you don't have to calculate deltas manually.

Tickers versus sleeps

A ticker is like a metronome. It ticks at a steady rhythm. If you miss a beat because you were busy playing a complex chord, the metronome keeps ticking. You can choose to catch up or ignore the missed beats. time.Sleep is like a stopwatch. You start it, wait, and then do something. If the action takes time, the stopwatch doesn't reset automatically. You have to manage the reset yourself.

Tickers are built on channels. This means they integrate naturally with Go's concurrency model. You can select on a ticker channel alongside other channels, like a context cancellation signal or a job queue. time.Sleep blocks the current goroutine. It cannot be cancelled without a separate mechanism. Tickers are the idiomatic choice for recurring tasks in long-running servers.

Minimal ticker example

Here's the simplest ticker: create one, loop over its channel, and stop it when done.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Ticker fires every 5 seconds. The channel buffers one value to prevent blocking the ticker goroutine.
	ticker := time.NewTicker(5 * time.Second)
	// Ensure the ticker goroutine is cleaned up when main exits.
	defer ticker.Stop()

	// Range over the channel blocks until a tick arrives.
	for range ticker.C {
		fmt.Println("Tick at", time.Now())
	}
}

The defer ticker.Stop() call is essential. time.NewTicker starts a background goroutine. If you don't stop the ticker, that goroutine runs forever. In a short-lived program, the process exits and the OS reclaims resources. In a long-running server, forgetting to stop tickers leaks goroutines. The goroutine count grows until the process consumes too much memory or file descriptors. Always stop tickers immediately after creation.

How the ticker works at runtime

time.NewTicker starts a background goroutine that sends the current time to a channel at the specified interval. The channel has a buffer of size one. This design prevents a slow receiver from blocking the ticker goroutine indefinitely. If the receiver hasn't read the previous tick when a new one fires, the new tick is dropped. The ticker waits for the buffer to clear before sending the next value.

This behavior guarantees that your job runs at most once per interval. The jobs never overlap. If your job takes longer than the interval, the ticker drops ticks until the job finishes. The rate is preserved relative to the wall clock. If you need overlapping jobs, a ticker isn't the right tool. You would need to spawn a new goroutine for each tick, which requires careful concurrency control.

The buffer size of one is a deliberate trade-off. A zero-buffer channel would block the ticker if the receiver is slow, causing the interval to stretch. A larger buffer would allow a backlog of ticks, potentially overwhelming the receiver when it catches up. Size one ensures the ticker never blocks and the receiver never sees more than one pending tick.

Realistic scheduler with context

Real schedulers need to stop gracefully. You pass a context.Context to control the lifecycle. The function returns when the context is cancelled. This pattern is standard in Go services. Contexts propagate cancellation signals through call stacks. Functions that accept a context should respect cancellation and deadlines.

Here's a scheduler that respects context cancellation and handles job errors.

package main

import (
	"context"
	"fmt"
	"time"
)

// RunJob executes the scheduled task.
// It returns an error if the job fails.
func RunJob() error {
	// Simulate work that might fail intermittently.
	if time.Now().Second()%10 == 0 {
		return fmt.Errorf("job failed")
	}
	fmt.Println("Job succeeded")
	return nil
}

// Schedule runs RunJob every interval until ctx is cancelled.
// Context is always the first parameter by convention.
func Schedule(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Println("Scheduler stopped")
			return
		case <-ticker.C:
			if err := RunJob(); err != nil {
				fmt.Printf("Job error: %v\n", err)
			}
		}
	}
}

func main() {
	// Create a context that cancels after 15 seconds.
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	Schedule(ctx, 5*time.Second)
}

The Schedule function takes ctx context.Context as the first parameter. This follows the Go convention for context propagation. The receiver name in methods is usually one or two letters matching the type, but functions don't have receivers. The context parameter name is conventionally ctx. The select statement checks two channels. If the context is cancelled, ctx.Done() returns a ready channel, and the function returns. If the ticker fires, the job runs. The defer cancel() call in main ensures the context resources are released.

Error handling uses if err != nil. This verbosity is intentional. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally swallow an error. Every error must be handled explicitly.

Pitfalls and common mistakes

The most common mistake is forgetting to call ticker.Stop. The ticker goroutine runs forever, holding resources. The program exits, but in long-running servers, this leaks goroutines. Always defer ticker.Stop immediately after creation. You can verify leaks by checking runtime.NumGoroutine over time. If the count grows, you have a leak.

Another pitfall is assuming ticker.Stop closes the channel. It does not. Stop prevents future ticks, but the channel remains open. If you use for range ticker.C and then call Stop, the loop hangs because the channel is never closed. Use a select with a done channel or context to break the loop safely. The compiler won't catch this. It's a runtime hang.

Tickers drop ticks. If your job takes longer than the interval, ticks are dropped. You don't get a backlog. If you need to process every tick, even if delayed, use a different pattern. You might need a worker pool with a job queue. The queue preserves jobs that arrive while the worker is busy.

If you forget to import time, the compiler rejects the program with undefined: time. If you try to use a variable before declaration, you get undefined: ticker. These are basic errors. The subtle errors are runtime hangs and leaks. Test your scheduler with long-running jobs and cancellation signals.

When to use tickers versus alternatives

Use time.Ticker when you need a recurring action at a fixed interval and can tolerate dropped ticks if the job runs long.

Use time.Sleep inside a loop when you need a fixed gap between the end of one job and the start of the next.

Use time.After when you need a one-off timeout or deadline for a single operation.

Use time.Timer when you need to schedule a single future event that can be cancelled.

Use a third-party cron library when you need complex schedules like "every Monday at 9 AM" or "first day of the month".

Use a worker pool with a job queue when jobs have variable durations and you need to bound concurrency or preserve missed jobs.

Tickers drop ticks. Sleeps guarantee gaps. Pick the behavior you need.

Where to go next