Go

Is time.After in a loop a memory leak? What changed in Go 1.23?

Question 362MediumGo 1.22 to 1.25
func consume(ch <-chan Event) {
    for {
        select {
        case e := <-ch:
            handle(e)
        case <-time.After(time.Minute):
            return // idle timeout
        }
    }
}

Before Go 1.23: every iteration created a timer that stayed in the runtime's timer heap until it fired. At 100k events per second that meant millions of live timers for a whole minute, which is a large temporary leak. The same was true of time.NewTicker tickers that were never stopped, and those leaked forever.

Go 1.23 and later, when the main module declares go 1.23 or newer:

  • Timers and tickers that nothing references can be garbage-collected even if they were never stopped or have not fired.
  • Timer channels are now synchronous (unbuffered), so after Reset or Stop no stale value is ever received. The old if !t.Stop() { <-t.C } drain idiom is no longer needed.

Good practice is still to reuse a single timer in hot loops, because each time.After call still allocates:

t := time.NewTimer(time.Minute)
defer t.Stop()
for {
    select {
    case e := <-ch:
        handle(e)
        t.Reset(time.Minute)
    case <-t.C:
        return
    }
}

What the interviewer is looking for: you know the behaviour depends on the go version in go.mod, which can be overridden with GODEBUG=asynctimerchan=1.

More on Memory, GC & Runtime Internals

All 38 Memory, GC & Runtime Internals questions