Go

time.Ticker, time.After and timers: what leaks, and what changed in Go 1.23?

Question 464HardGo 1.22 to 1.25

A time.Ticker fires until you call Stop(); always defer t.Stop(). Before Go 1.23, an unstopped Ticker/Timer was never garbage-collected, and time.After inside a hot select loop allocated a timer per iteration that lived until it fired — a memory "leak" for long durations.

func poll(ctx context.Context, every time.Duration, fn func()) {
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			fn()
		}
	}
}

// Idle timeout that resets on each message: reuse one timer.
func consume(msgs <-chan string) {
	idle := time.NewTimer(30 * time.Second)
	defer idle.Stop()
	for {
		select {
		case m, ok := <-msgs:
			if !ok {
				return
			}
			fmt.Println(m)
			idle.Reset(30 * time.Second) // Go 1.23+: safe, no stale value
		case <-idle.C:
			fmt.Println("idle, exiting")
			return
		}
	}
}

Go 1.23 changes (module go 1.23+): unreferenced timers/tickers are GC-eligible even if not stopped, and timer channels are synchronous (unbuffered), so after Stop/Reset no stale tick is received — the old "drain the channel before Reset" dance is unnecessary. (Unchanged: tickers have always dropped ticks for slow receivers rather than queueing them.) GODEBUG=asynctimerchan=1 restores the old buffered-channel behavior. Still call Stop for clarity and to stop work promptly.

More on Standard Library, HTTP & Systems Design in Go

All 35 Standard Library, HTTP & Systems Design in Go questions