Skip to content
gohacks.dev
Concurrency

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.

7 min read
Summarize in AI
Flat illustration of a grid of heap cells with the garbage collector sweeping across it.

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:

Go
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:

main.goGo
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)
}
Text
heap held by a million unused timers (MiB): 252

With 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:

main.goGo
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)
}
Text
heap held by a million unused timers (MiB): 0

The 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:

main.goGo
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")
	}
}
Text
stale value received after Stop

Under 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:

main.goGo
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")
	}
}
Text
no stale value

On 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:

main.goGo
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")
}
Text
handled 3 events

One 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.

Taggedgo 1 23
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.