- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen the shape. A Go service with a metrics loop that flushes
every ten seconds. A poller that checks a queue on a time.Ticker.
A retry helper built on time.After. All of it reads clean in
review. Then a week later the pprof heap profile shows a slow,
straight-line climb in time.Timer allocations, and nobody touched
the timing code.
The time package is one of the friendliest corners of the Go
standard library right up until you have to stop something or reset
it. Tickers leak if you forget one method call. time.After in a
loop leaks in a way that passes every test. And the old rule for
resetting a timer was so subtle that the Go team rewrote the runtime
in 1.23 to make it unnecessary. Here are the traps and where they
went.
A Ticker you never Stop keeps its runtime alive
time.NewTicker hands you a *Ticker with a channel that fires on
an interval. The part people skip: the ticker registers itself with
the runtime timer heap, and it stays there, firing forever, until
you call Stop.
func poll(ctx context.Context, q *Queue) {
t := time.NewTicker(5 * time.Second)
for {
select {
case <-ctx.Done():
return
case <-t.C:
q.drain()
}
}
}
The context cancels, the function returns, and the ticker is still
registered with the runtime. Its channel has no reader now, so the
sends drop, but the ticker itself keeps ticking on the timer heap.
Spawn one of these per request and you have a leak that grows with
traffic.
The fix is one line and a defer:
func poll(ctx context.Context, q *Queue) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
q.drain()
}
}
}
defer t.Stop() right after NewTicker is the habit to build. The
same goes for any *Timer you create with NewTimer and might
abandon before it fires.
time.After in a loop is a leak that hides in plain sight
time.After(d) is the convenient one. It returns a channel that
fires once after d, no variable to hold, no Stop to remember. In
a select it reads beautifully:
for {
select {
case msg := <-in:
handle(msg)
case <-time.After(30 * time.Second):
return errIdleTimeout
}
}
The intent is "give up if no message arrives for 30 seconds." The
bug is that time.After builds a brand-new timer on every loop
iteration. Each message that arrives sends the loop back around and
allocates another 30-second timer. On a busy channel that is
thousands of live timers, each one holding its slot until its full
30 seconds elapse.
The idiomatic fix is a single *Timer you reset each iteration:
t := time.NewTimer(30 * time.Second)
defer t.Stop()
for {
select {
case msg := <-in:
handle(msg)
t.Reset(30 * time.Second)
case <-t.C:
return errIdleTimeout
}
}
One timer, reused. No per-iteration allocation, no pile. And that
Reset call is exactly where the old, sharp edge lived.
The drain-before-Reset rule, and why it cut people
Before Go 1.23, the Timer.Reset documentation carried a warning
that launched a thousand Stack Overflow answers. Resetting a timer
was only safe if the timer was stopped and its channel drained
first. The reason: a timer channel had a buffer of one. If the timer
fired and nobody had read t.C yet, a stale value sat in the
buffer. Reset the timer, and your next receive on t.C would pull
that old value instead of the new one.
So the "correct" pre-1.23 pattern looked like this:
// Pre-1.23 dance. Do not copy into new code.
if !t.Stop() {
<-t.C
}
t.Reset(d)
Stop returns false if the timer already fired or was stopped, so
you drain the leftover value with <-t.C, then Reset. Correct in a
single goroutine. A deadlock waiting to happen everywhere else.
If another goroutine had already read t.C, that <-t.C blocked
forever, because there was nothing left to drain. Any select loop
that sometimes consumed the tick and sometimes didn't could not use
this pattern safely. People wrapped it in non-blocking selects,
guessed, and shipped subtle races either way.
What Go 1.23 changed
Go 1.23 rewrote the timer implementation and closed both traps at
the language level.
Timer channels are now unbuffered. The channel behind a Timer
or Ticker has capacity zero. After Stop or Reset returns, it
is guaranteed that no stale value from before the call will be
received. The whole reason the drain dance existed is gone. In
Go 1.23+ you reset a timer with one line:
t.Reset(d) // that's the whole thing now
No Stop, no drain, no non-blocking select to guard it. If you are
on 1.23 or newer, delete the old dance wherever you find it.
Unstopped timers get collected. Before 1.23, a Timer or
Ticker that you stopped referencing was not garbage-collected
until it fired. That is what made time.After in a hot loop a real
memory problem: the abandoned timers survived until their duration
elapsed. In 1.23+, an unreferenced timer becomes eligible for GC
right away, even if you never called Stop.
That softens the time.After leak but does not erase the reason to
avoid it. The timers still allocate, still cost, and in a tight loop
still churn the collector. Stop also stays worth calling: it frees
the timer immediately instead of waiting for the next GC cycle, and
it is the clearest signal to the next reader that the timer is done.
One footgun to know about: the new behavior only applies when your
module targets Go 1.23 or later in go.mod. The change is gated
behind the language version, and you can force the old asynchronous
channels back with GODEBUG=asynctimerchan=1 if some code depended
on the buffered timing. Most code should not.
What to actually do now
The rules that survive every Go version:
- Every
NewTickerandNewTimergets adefer Stop()next to it. Treat the timer like a file handle. - Never call
time.Afterinside a loop. Hold one*TimerandResetit, or use a*Tickerfor a fixed cadence. - On Go 1.23+, reset with a bare
t.Reset(d). Delete anyif !t.Stop() { <-t.C }you inherited from older code or older blog posts. - On Go 1.22 or earlier, keep the drain dance, but only in code where a single goroutine owns the timer. If ownership is shared, redesign so it is not.
Check your go.mod before you trust the new behavior. A repo with
go 1.21 in it still runs the old buffered timers even on a 1.24
toolchain, because the runtime honors the module's language
version. The line in go.mod is what decides which timer semantics
you get.
Timers are a small API with a long memory. The method you forget to
call does not error, it just quietly holds a slot in the runtime
until traffic makes it visible. Go 1.23 removed the worst of the
sharp edges. The discipline around Stop and Reset is what keeps
the rest of them off your heap profile.
If you want the layer under this — how the runtime timer heap
parks and wakes goroutines, and why the 1.23 channel change fixed a
correctness bug rather than shaving latency — that runtime depth is
what The Complete Guide to Go Programming is built for. Hexagonal
Architecture in Go is the companion for keeping timers and tickers
behind a port, so a poller or a retry loop is an adapter detail
instead of something bleeding through your domain code.

Top comments (0)