Go's time.After: The Timer Leak Hiding in Every select Loop
This pattern turns up in almost every Go codebase that reads from a channel with a timeout: a heartbeat loop, a connection reader, a worker waiting for the next job.
func consume(ch <-chan Message, timeout time.Duration) {
for {
select {
case msg := <-ch:
handle(msg)
case <-time.After(timeout):
log.Println("timed out waiting for message")
return
}
}
}
It reads cleanly, it compiles, it passes review. It is also, on older Go versions, a slow leak: every time ch wins the race, the time.After(timeout) call from that iteration is thrown away half-finished, and nothing ever reads from its channel again. In Go's own vocabulary, that channel is never "drained": nobody ever performs the receive that would flush its value and let the timer be forgotten about cleanly. Instead it just sits there, running down its own clock in the background, for a duration up to timeout, before it fires into a channel nobody is listening on.
One abandoned timer is nothing. The problem is that consume calls time.After again on the very next iteration, and the one after that, for as long as messages keep arriving faster than the timeout.
What "not draining" actually costs
Internally, each timer you create is registered with the Go runtime in a heap of pending timers (one per logical processor since the timer implementation was made more scalable back in Go 1.14), so the scheduler knows when to wake up and fire it without scanning every timer in the program. Stopping a timer, or letting it fire, is what removes it from that heap.
Before Go 1.23, a Timer that was neither stopped nor allowed to fire stayed in that heap indefinitely, and because the runtime's own timer heap held a live reference to it, the garbage collector could not reclaim the Timer value either, no matter how completely your Go code had forgotten it existed. The object was reachable from the runtime, just not from anything you could see. The standard library documentation used to say this outright: the underlying timer "will not be recovered by the garbage collector until the timer fires."
So in the consume example, if messages arrive every millisecond and the timeout is 30 seconds, you get a rough steady state of around 30,000 live-but-pointless timers sitting in the runtime's heap at any given moment, each one only disappearing when its own 30 seconds finally run out. That is not a crash, and it is bounded rather than unbounded, but it is real heap memory and real timer-heap churn that has nothing to do with the actual work the loop is doing. On a busy service with several such loops it shows up as steadily elevated memory and GC time that nobody can explain from the business logic, because the business logic looks completely innocent.
The old fix: one timer, reused, drained by hand
Before Go 1.23 the accepted answer was to stop creating a new timer every iteration and instead reuse a single one, resetting it each time round the loop. That requires the drain dance, because Timer.Stop returning false means the timer had already fired (or was already stopped), and on the old buffered timer channel a fired-but-unread value could still be sitting in the channel's one-element buffer waiting to be collected on the next receive:
func consume(ch <-chan Message, timeout time.Duration) {
t := time.NewTimer(timeout)
defer t.Stop()
for {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(timeout)
select {
case msg := <-ch:
handle(msg)
case <-t.C:
log.Println("timed out waiting for message")
return
}
}
}
This works, but it is fiddly enough that people got it wrong constantly: reset without stopping first, drain unconditionally and occasionally block forever on an empty channel, or stop and drain in the wrong order relative to Reset. The pattern was correct and the ergonomics were bad, which is exactly the kind of thing that ends up copy-pasted wrong from Stack Overflow answers with hundreds of upvotes.
Go 1.23 fixed the actual problem, not just the symptom
Go 1.23 made two changes to time.Timer and time.Ticker together, and it is worth being precise about which one fixes which half of this mess. First, timers and tickers no longer referenced by your program become eligible for garbage collection immediately, whether or not Stop was ever called. Second, the channel a Timer delivers on became genuinely unbuffered, capacity zero, instead of the old one-element buffer, which is what guarantees a Reset or Stop call can no longer race against a stale value left over from before it.
The first change is what kills the leak in the naive consume loop: an abandoned time.After timer is now just an ordinary unreachable Go value the moment the losing select case is taken, and the GC treats it like any other garbage. The second change is what makes the manual stop-and-drain dance unnecessary: there is no buffer left for a stale tick to hide in. The time.After documentation was updated accordingly and now says, plainly, "there is no reason to prefer NewTimer when After will do."
The one thing worth checking before you rely on any of this: both behaviours are gated on the go line in your module's go.mod naming 1.23 or later, not just on the Go toolchain version you happen to have installed. Build an older module (go 1.21 in go.mod, say) with a Go 1.23 compiler and you silently get the old buffered-channel, GC-blocking behaviour, because the runtime checks what the module declared it targets. There is also a GODEBUG=asynctimerchan=1 setting if you ever need to force the old behaviour back for compatibility testing.
So is time.After in a loop fine now, or not
For correctness, yes, on a module targeting Go 1.23 or later, the naive version at the top of this article is fine: no leak, no stale-value race, and the standard library's own advice now points at it rather than away from it.
For efficiency, it depends how hot the loop is. Each call to time.After still allocates a Timer and inserts it into the runtime's timer heap, an operation with real (if small) cost, and the GC fix means that cost stops accumulating rather than that it stops happening. In a loop processing messages every few microseconds, reusing a single timer with Reset is still measurably cheaper than allocating a fresh one on every pass, even though skipping the reuse no longer risks the runaway memory growth it used to. If the loop is timing out against a context.Context deadline anyway rather than a fixed duration, that context already owns and stops its own internal timer when cancelled, and reaching for time.After alongside it is redundant either way.
If you maintain anything that still supports Go versions or go.mod lines earlier than 1.23, though, the leak described here is not a historical curiosity, it is live in your codebase right now, and the fix is still the manual reuse-and-drain pattern above, not the one-liner.