Timers, Tickers, and time.After Pitfalls
Timers, Tickers, and time.After Pitfalls
Overview
Timers are a frequent source of subtle leaks and wakeups in hot paths. The runtime keeps a timer heap (per-P structures in modern Go). Stopping and resetting correctly matters as much as creating timers.
Diagram: Timer heap cost
flow:
[Heap]
|
v
[GC]
APIs
| API | Use |
|---|---|
time.NewTimer |
One-shot; must Stop/Reset carefully |
time.NewTicker |
Periodic; always Stop |
time.After |
Convenient; allocates a new timer each call |
time.AfterFunc |
Fire a function on a timer thread-ish path |
The Classic Pitfall
// BAD in a busy loop / per-request select
select {
case <-ch:
case <-time.After(time.Second): // new timer every iteration
}Each time.After creates a timer that lives until it fires (or is GC’d after fire). Under load this is allocation + timer-heap churn.
Prefer:
t := time.NewTimer(time.Second)
defer t.Stop()
select {
case <-ch:
if !t.Stop() {
select {
case <-t.C:
default:
}
}
case <-t.C:
}Or reuse with Reset after successful Stop/drain per current docs for your Go version.
Tickers
tk := time.NewTicker(100 * time.Millisecond)
defer tk.Stop()
for {
select {
case <-ctx.Done():
return
case <-tk.C:
// work
}
}A stopped ticker is not restarted with Reset in older Go the same way as timers — check version docs.
Runtime View
Timers are not free threads. They are heap entries processed by the runtime; expired timers make Gs runnable. Huge numbers of far-future timers cost memory; huge numbers of near-term timers cost CPU in the timer processor.
Context Timeouts
context.WithTimeout uses timers under the hood. Prefer one timeout at the edge and derive children, rather than stacking independent After calls at every layer without budget math.
Experiment
go test -bench=. -benchmempackage timers_test
import (
"testing"
"time"
)
func BenchmarkTimeAfter(b *testing.B) {
ch := make(chan struct{})
for i := 0; i < b.N; i++ {
select {
case <-ch:
case <-time.After(time.Hour):
default:
}
}
}
func BenchmarkTimerReuse(b *testing.B) {
t := time.NewTimer(time.Hour)
defer t.Stop()
ch := make(chan struct{})
for i := 0; i < b.N; i++ {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(time.Hour)
select {
case <-ch:
case <-t.C:
default:
}
}
}What to notice: time.After in a loop allocates; reuse patterns dominate under -benchmem.
Try next: Find time.After in a hot select in your codebase with rg 'time\\.After'.