Go

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

Question 250MediumGo 1.22 to 1.25
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 timer that could not be garbage-collected until it fired. With a busy msgs channel this piled up thousands of one-minute timers, a well-known memory leak. Stop/Reset also had subtle rules about draining the channel.

Go 1.23 (when go.mod is 1.23 or higher) changed two things:

  • Unreferenced timers and tickers can be collected even when they have not fired or been stopped, so this pattern no longer leaks.
  • Timer channels became synchronous (unbuffered). After Reset or Stop, no stale value can be received, and the old "drain before Reset" idiom is no longer needed.

Allocating a timer per iteration is still wasteful in hot loops. Reuse one timer:

t := time.NewTimer(time.Minute)
defer t.Stop()
for {
	select {
	case msg := <-msgs:
		handle(msg)
		t.Reset(time.Minute) // 1.23+: safe without draining
	case <-t.C:
		log.Println("idle")
		t.Reset(time.Minute)
	case <-ctx.Done():
		return
	}
}

More on Concurrency Patterns & sync

All 38 Concurrency Patterns & sync questions