time.After in a Select Loop: The Timer Pile-Up2026-05-17
This function watches a channel for events and logs a heartbeat if nothing arrives within five seconds. It runs for months without crashing, but memory creeps upward and eventually the service OOMs. Where's the leak?
package main
import (
"log"
"time"
)
type Event struct{ ID int }
func watch(events <-chan Event) {
for {
select {
case e := <-events:
handle(e)
case <-time.After(5 * time.Second):
log.Println("no events in 5s, still alive")
}
}
}
func handle(e Event) {
// process event
}
Every time the select evaluates its cases, time.After(5 * time.Second) is called again. Each call allocates a fresh *time.Timer backed by a runtime entry in the timer heap, plus a one-shot channel. The timer holds a reference to itself inside the runtime until it fires — and crucially, on every iteration where an event arrives first, the timer from that iteration is simply abandoned, not stopped.
Under Go's pre-1.23 semantics, abandoned timers stay alive in the runtime heap until their full 5-second duration elapses. If events arrive at 10,000/sec, you have roughly 10,000 × 5 = 50,000 pending timers at any moment, each consuming memory for the timer struct, the channel, and the closure. Throughput goes up → memory grows linearly → eventually OOM.
The trap is that the code looks idiomatic. The time.After idiom in a select appears in countless tutorials. It's safe when the loop blocks on slow events; it's a slow-motion bomb when events are frequent.
Go 1.23 improved this — abandoned timers become eligible for GC sooner — but the fundamental pattern is still wasteful, and your service may not be on the latest runtime. The right fix is to allocate the timer once and reset it:
func watch(events <-chan Event) {
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case e := <-events:
handle(e)
if !timer.Stop() {
<-timer.C // drain if it fired during handle()
}
timer.Reset(5 * time.Second)
case <-timer.C:
log.Println("no events in 5s, still alive")
timer.Reset(5 * time.Second)
}
}
}
The drain dance on timer.Stop() matters: Stop returns false if the timer already fired and its value is sitting unread in timer.C. Failing to drain leaves stale data that the next select picks up immediately, defeating the timeout.
For cancellation rather than periodic timeout, prefer context.WithTimeout — it composes properly with downstream calls and the runtime handles cleanup when the context is cancelled. But inside a hot loop, the reusable time.Timer is the only allocation-free option.
This bug is invisible in unit tests with low event rates, invisible in staging with synthetic load, and devastating in production traffic spikes. Profile with pprof's goroutine and heap views — a tower of runtime timers is the signature.
time.After inside a hot select loop allocates a timer per iteration that lingers until expiration — use a single time.NewTimer with Reset (and the drain dance) when events can arrive faster than your timeout.
