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.