Go Ticker and Timer: Periodic Execution

Go's `time` package provides two essential primitives for time-based code execution: `Timer` and `Ticker`. While they seem similar at first glance, they serve fundamentally different purposes. A...

Key Insights

  • Timers execute code once after a delay while Tickers repeat at fixed intervals—forgetting to call Stop() on either causes goroutine leaks that will eventually exhaust your resources
  • Always combine tickers with context cancellation in production code to enable graceful shutdown and prevent orphaned goroutines when your application terminates
  • Ticker intervals represent target durations, not guarantees—if your tick handler takes longer than the interval, ticks will be dropped to prevent unbounded channel growth

Introduction to Time-Based Execution in Go

Go’s time package provides two essential primitives for time-based code execution: Timer and Ticker. While they seem similar at first glance, they serve fundamentally different purposes. A Timer fires once after a specified duration—think of it as setting an alarm. A Ticker, on the other hand, fires repeatedly at regular intervals—like a metronome keeping steady time.

You’ll reach for these tools when implementing rate limiting, polling external services, triggering periodic cleanup tasks, implementing timeouts, or building any system that needs time-aware behavior. Understanding the distinction and proper usage patterns is critical because misuse leads to goroutine leaks, one of Go’s most insidious production bugs.

Timer Basics: One-Time Delayed Execution

A Timer represents a single event in the future. When the timer expires, it sends the current time on its channel. Here’s the simplest possible timer:

package main

import (
    "fmt"
    "time"
)

func main() {
    timer := time.NewTimer(2 * time.Second)
    
    fmt.Println("Waiting for timer...")
    <-timer.C
    fmt.Println("Timer fired!")
}

This blocks for 2 seconds, then continues. The timer’s C channel receives exactly one value when the duration elapses.

For callback-style execution without blocking, use time.AfterFunc():

func main() {
    timer := time.AfterFunc(2*time.Second, func() {
        fmt.Println("Timer callback executed!")
    })
    
    // Do other work while timer runs in background
    fmt.Println("Timer scheduled, continuing work...")
    time.Sleep(3 * time.Second) // Keep main alive
    
    timer.Stop() // Won't do anything since timer already fired
}

The callback executes in its own goroutine, so be mindful of concurrent access to shared state.

Timers can be stopped before they fire, which is crucial when implementing timeouts:

func fetchWithTimeout(url string) error {
    timer := time.NewTimer(5 * time.Second)
    defer timer.Stop() // Critical: prevent goroutine leak
    
    resultCh := make(chan error)
    go func() {
        resultCh <- doSlowFetch(url)
    }()
    
    select {
    case err := <-resultCh:
        return err
    case <-timer.C:
        return fmt.Errorf("request timed out")
    }
}

func doSlowFetch(url string) error {
    time.Sleep(3 * time.Second)
    return nil
}

Always call Stop() on timers you’re done with. While a single timer leak won’t crash your app, thousands of them will.

Ticker Fundamentals: Repeating Periodic Tasks

A Ticker sends the current time on its channel at regular intervals until you stop it. Unlike timers, tickers keep firing indefinitely:

func main() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop() // Always clean up
    
    count := 0
    for range ticker.C {
        count++
        fmt.Printf("Tick %d at %s\n", count, time.Now().Format("15:04:05"))
        
        if count >= 5 {
            break
        }
    }
}

This prints a message every second for 5 iterations. The defer ticker.Stop() is non-negotiable—forgetting it leaves a goroutine running forever, leaking memory.

A more idiomatic pattern uses an explicit loop with the ticker channel:

func runPeriodicTask() {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()
    
    for {
        select {
        case t := <-ticker.C:
            fmt.Printf("Executing task at %s\n", t.Format("15:04:05"))
            performCleanup()
        }
    }
}

func performCleanup() {
    // Your periodic work here
    fmt.Println("Cleaning up old sessions...")
}

This pattern runs forever, which is fine for background workers, but production code needs graceful shutdown.

Practical Patterns and Best Practices

Real applications need to stop gracefully. Combine tickers with context for clean shutdown:

func worker(ctx context.Context) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    
    for {
        select {
        case <-ticker.C:
            fmt.Println("Performing periodic work")
            processQueue()
        case <-ctx.Done():
            fmt.Println("Shutting down worker")
            return
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    
    go worker(ctx)
    
    time.Sleep(20 * time.Second)
    cancel() // Graceful shutdown
    time.Sleep(1 * time.Second) // Give worker time to clean up
}

func processQueue() {
    // Simulated work
    time.Sleep(100 * time.Millisecond)
}

Rate limiting API calls is a killer use case for tickers:

func rateLimitedAPIClient(requests []string) {
    ticker := time.NewTicker(time.Second / 10) // 10 requests per second
    defer ticker.Stop()
    
    for _, req := range requests {
        <-ticker.C // Wait for next tick
        makeAPICall(req)
    }
}

func makeAPICall(endpoint string) {
    fmt.Printf("Calling API: %s at %s\n", endpoint, time.Now().Format("15:04:05.000"))
}

This guarantees you never exceed 10 requests per second, protecting you from rate limit errors.

The goroutine leak problem deserves emphasis. This code leaks:

// BAD: Leaks a goroutine
func badExample() {
    ticker := time.NewTicker(1 * time.Second)
    // Forgot ticker.Stop()
    
    for i := 0; i < 5; i++ {
        <-ticker.C
        fmt.Println("Tick")
    }
    // Function returns but ticker goroutine keeps running
}

Each call to badExample() leaves a goroutine running forever. Call this in a loop and watch your memory usage climb.

Advanced Techniques

Sometimes you need to change ticker intervals dynamically. You can’t modify a ticker’s interval, so stop it and create a new one:

func adaptiveTicker(ctx context.Context) {
    interval := 1 * time.Second
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    
    for {
        select {
        case <-ticker.C:
            load := getCurrentLoad()
            fmt.Printf("Current load: %.2f\n", load)
            
            newInterval := calculateInterval(load)
            if newInterval != interval {
                ticker.Stop()
                interval = newInterval
                ticker = time.NewTicker(interval)
                fmt.Printf("Adjusted interval to %s\n", interval)
            }
        case <-ctx.Done():
            return
        }
    }
}

func getCurrentLoad() float64 {
    return 0.5 + (0.5 * float64(time.Now().Unix()%10) / 10)
}

func calculateInterval(load float64) time.Duration {
    if load > 0.8 {
        return 5 * time.Second // Back off under high load
    }
    return 1 * time.Second
}

Coordinating multiple tickers requires careful use of select:

func multipleTimers(ctx context.Context) {
    fastTicker := time.NewTicker(1 * time.Second)
    slowTicker := time.NewTicker(5 * time.Second)
    defer fastTicker.Stop()
    defer slowTicker.Stop()
    
    for {
        select {
        case <-fastTicker.C:
            fmt.Println("Fast tick - checking queue")
        case <-slowTicker.C:
            fmt.Println("Slow tick - generating report")
        case <-ctx.Done():
            return
        }
    }
}

Here’s a simple job scheduler using tickers:

type Job struct {
    Name     string
    Interval time.Duration
    Task     func()
}

func scheduler(ctx context.Context, jobs []Job) {
    for _, job := range jobs {
        go runJob(ctx, job)
    }
    <-ctx.Done()
}

func runJob(ctx context.Context, job Job) {
    ticker := time.NewTicker(job.Interval)
    defer ticker.Stop()
    
    for {
        select {
        case <-ticker.C:
            fmt.Printf("Running job: %s\n", job.Name)
            job.Task()
        case <-ctx.Done():
            return
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    
    jobs := []Job{
        {Name: "Cleanup", Interval: 3 * time.Second, Task: func() { fmt.Println("  Cleaning...") }},
        {Name: "Backup", Interval: 5 * time.Second, Task: func() { fmt.Println("  Backing up...") }},
    }
    
    scheduler(ctx, jobs)
}

Performance Considerations and Common Pitfalls

The most critical pitfall is forgetting Stop(). Each ticker or timer allocates a goroutine internally. Without stopping them, these goroutines accumulate, consuming memory and CPU. In production, this manifests as gradual memory growth over days or weeks.

Ticker drift is another consideration. If your tick handler takes longer than the ticker interval, Go drops ticks rather than queuing them. This prevents unbounded channel growth:

ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for range ticker.C {
    time.Sleep(2 * time.Second) // Slow work
    // You'll only see a tick every 2 seconds, not every 1 second
}

Tickers aren’t nanosecond-precise. They’re accurate to within milliseconds on most systems, but don’t rely on them for high-precision timing. The OS scheduler introduces variability.

Never perform blocking operations directly in AfterFunc callbacks without careful consideration—they run in the timer’s goroutine and can delay other timers if you’re not careful.

Conclusion

Use Timer for one-time delayed execution: timeouts, scheduled tasks, debouncing. Use Ticker for repeated periodic work: polling, rate limiting, health checks, cleanup routines.

The golden rule: always call Stop() when you’re done. Use defer immediately after creating the timer or ticker. Combine tickers with context for production-grade graceful shutdown.

Tickers provide regular intervals, not guaranteed execution times. If your handler is slow, ticks get dropped. This is a feature, not a bug—it prevents runaway memory growth.

Master these primitives and you’ll handle time-based operations in Go with confidence, avoiding the goroutine leaks that plague many Go applications in production.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.