Time After Leaks and the Timer Changes in Go 1.23 Fixed
Why time.After in a loop used to leak memory, what Go 1.23 changed for timers and tickers, how go.mod gates it, and the Reset pattern that remains.

For more than a decade, one of the first Go performance tips was "don't call time.After in a loop". It was good advice, hard to see in code review, and the cause of memory growth in a lot of services. In Go 1.23 the runtime changed so that most of the problem went away. The change is conditional on a line in go.mod, which means that two programs with identical code can behave differently, and that you need to know which one yours is.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64), compiled with go.mod go lines 1.22 and 1.23
This article explains the old behavior with a measurement, the new one, how the version gate works, and what is still worth doing. It relates to select with timeouts and tickers. Everything ran on Go 1.25.6, with the same program compiled under two go directives.
The old problem
time.After(d) returns a channel that receives the time after d elapses. It is shorthand for creating a timer and returning its channel, and that timer lives in the runtime's timer heap until it fires. The common use was inside a select:
for {
select {
case msg := <-ch:
handle(msg)
case <-time.After(time.Minute): // a new timer every iteration
return
}
}Every pass through the loop created a new one-minute timer, and when ch delivered, the timer's channel was abandoned, but the timer itself stayed in the heap for a full minute. A loop handling ten thousand messages a second therefore held about 600,000 live timers at any time, each a few hundred bytes, none of them useful. The memory was not leaked forever, since each timer was released after it fired, but with long durations (an hour for an idle timeout) it grew until the timers started firing.
The program below measures this. It creates a million time.After(time.Hour) timers, reads none of them, drops every reference, forces a garbage collection and reports the heap that remains:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
for i := 0; i < 1_000_000; i++ {
_ = time.After(time.Hour) // never received from, never stopped
}
runtime.GC()
runtime.ReadMemStats(&after)
fmt.Println("heap held by a million unused timers (MiB):", (after.HeapAlloc-before.HeapAlloc)>>20)
}heap held by a million unused timers (MiB): 252With the module's go line set to 1.22, a million abandoned timers held 252 MiB after a full collection on this machine, because the runtime still references each timer until it fires an hour from now. That is the pre-1.23 behavior, which Go preserves for modules that declare an older version.
What Go 1.23 changed
The Go 1.23 release notes describe two changes to time.Timer and time.Ticker.
First, timers and tickers that are no longer referred to by the program become eligible for garbage collection immediately, even if Stop has not been called. Earlier versions did not collect unstopped timers until after they had fired and never collected unstopped tickers. The same program with a go line of 1.23 shows the effect:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
for i := 0; i < 1_000_000; i++ {
_ = time.After(time.Hour) // never received from, never stopped
}
runtime.GC()
runtime.ReadMemStats(&after)
fmt.Println("heap held by a million unused timers (MiB):", (after.HeapAlloc-before.HeapAlloc)>>20)
}heap held by a million unused timers (MiB): 0The identical code now leaves essentially nothing behind: the runtime notices that nothing refers to the channel and lets the timer go. The time.After-in-a-loop pattern is no longer a memory leak on a current module.
Second, the timer channel is now unbuffered, with capacity zero, which changes how Stop and Reset behave. Previously the channel held one value, so a timer that fired just before you called Stop left a value in the channel that a later receive would take as a fresh expiry. The release notes state the guarantee: for any call to Reset or Stop, no stale values prepared before that call will be sent or received after the call. A program shows the difference:
package main
import (
"fmt"
"time"
)
func main() {
t := time.NewTimer(5 * time.Millisecond)
time.Sleep(20 * time.Millisecond) // the timer has fired by now
t.Stop()
select {
case <-t.C:
fmt.Println("stale value received after Stop")
default:
fmt.Println("no stale value")
}
}stale value received after StopUnder a go 1.22 module this prints stale value received after Stop: the fired timer's value was still sitting in the one-slot buffer. The same file under go 1.23 prints no stale value, because Stop guarantees that a value prepared earlier will not be delivered afterwards:
package main
import (
"fmt"
"time"
)
func main() {
t := time.NewTimer(5 * time.Millisecond)
time.Sleep(20 * time.Millisecond) // the timer has fired by now
t.Stop()
select {
case <-t.C:
fmt.Println("stale value received after Stop")
default:
fmt.Println("no stale value")
}
}no stale valueOn the newer version, the answer is the second line. That removes the old advice to drain the channel after Stop (if !t.Stop() { <-t.C }), which was easy to get wrong and could deadlock if the value had already been received. A side effect of the unbuffered channel is that len(t.C) and cap(t.C) return 0 now, so code that polled the length to decide whether a receive would succeed must use a non-blocking receive instead.
The version gate
The new behavior is not automatic. The release notes say it is enabled only when the main program's module has a go line of 1.23.0 or later, and that older programs keep the old behavior when built with a newer toolchain. This is how Go keeps old programs working: the toolchain version does not change a program's semantics; the language version in go.mod does.
Two consequences follow. Upgrading the compiler in your Dockerfile to 1.25 does not by itself remove the leak; the go line has to move too (go mod edit -go=1.23, or later). And a library inherits the behavior of the main module that builds it, not its own go line, since the setting applies to the whole program; a library that depends on stale-free Stop semantics cannot assume them if a user builds it into a program with an older directive. For the other direction, the GODEBUG setting asynctimerchan=1 restores the old channel behavior in a module that is on 1.23 or later, for code that depended on it. Setting it should be a temporary measure while you fix the code.
What is still worth doing
The leak is gone, and a few habits remain good.
Reuse a timer in a hot loop. time.After allocates a timer and a channel on every call. In a loop that runs millions of times, creating one time.Timer and calling Reset each iteration avoids the allocations:
package main
import (
"fmt"
"time"
)
func main() {
events := make(chan int, 8)
for i := 1; i <= 3; i++ {
events <- i
}
close(events)
idle := time.NewTimer(50 * time.Millisecond)
defer idle.Stop()
handled := 0
loop:
for {
select {
case _, ok := <-events:
if !ok {
break loop
}
handled++
idle.Reset(50 * time.Millisecond) // push the idle deadline back
case <-idle.C:
fmt.Println("idle timeout")
break loop
}
}
fmt.Println("handled", handled, "events")
}handled 3 eventsOne timer serves the whole loop, and Reset moves its deadline. On Go 1.23 and later this is correct as written, with no drain step. On older modules, Reset needed the careful stop-and-drain dance, which is why the allocation-per-iteration time.After was so popular.
Stop what you start, when it is cheap to do so. Unreferenced tickers are now collectable, but a ticker that is still referenced keeps firing and keeps waking a goroutine. defer ticker.Stop() costs a line and states intent (tickers).
Prefer a context deadline for timeouts that cross functions. A time.After in a select is a local timeout; a request-wide limit belongs in a context (context with timeout).
Checking your own code
Search for time.After and time.Tick inside loops, and NewTicker without Stop. Check the go line of the main module: below 1.23, those are leaks; at 1.23 or later, they are allocations. Look for old drain idioms (if !t.Stop() { <-t.C }) and for code that reads len(t.C); both are candidates for simplification. And if the service's memory climbs in a pattern that follows request volume, take a heap profile before blaming anything else: timers show up in it as time.NewTimer and time.After. The full description is in the Go 1.23 release notes, and the type is documented in the time package, whose text on Reset and Stop describes the current guarantees.


