Does time.After in a loop leak memory? How did Go 1.23 change this?
for {
select {
case msg := <-msgs:
handle(msg)
case <-time.After(time.Minute): // new timer every iteration
log.Println("idle")
}
}
Before Go 1.23: every iteration created a new Timer with a buffered channel, and the runtime timer heap kept it alive until it fired. With thousands of messages per second and a one-minute timeout, millions of live timers piled up: a real memory leak and CPU cost, and a classic interview question.
Go 1.23+ (when the module's go line is >= 1.23): unreferenced timers and tickers become eligible for GC right away, even if they are unstopped. Timer channels are also now synchronous (unbuffered), so Reset/Stop never return a stale value. The memory leak is gone, but you still allocate one timer per iteration.
The idiomatic fix works on every version:
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case msg := <-msgs:
handle(msg)
t.Reset(time.Minute) // Go 1.23+: no drain needed
case <-t.C:
log.Println("idle")
t.Reset(time.Minute)
}
}
Before 1.23, a correct Reset required if !t.Stop() { <-t.C }, and that drain could itself deadlock if the value had already been received. Mentioning this history marks you as senior.
More on Channels & select
- Q197What are directional channel types and why use them?
- Q198How do you implement a timeout on a channel operation? Compare
time.After,time.NewTimer, andcontext. - Q200What's the difference between
time.Tickerand repeatedtime.After? What happens if the consumer is slow? - Q201How do you do a non-blocking send or receive?
- Q202What does this print? (select evaluation order)
- Q203How would you implement a semaphore / bounded concurrency with channels?