
Tricky parts of Golang. The time.Timer Reset Race
You have a timer loop that resets after each event. The code looks right. The tests pass consistently. Then, once every few hours in production, a value appears in the channel that was not supposed to be there. It corrupts state in a way that is impossible to reproduce locally, because it requires a race window measured in nanoseconds.
This is the time.Timer reset race, and it is one of the most carefully documented traps in the Go standard library — documented precisely because so many people fall into it.
The Trap
Here is the pattern:
t := time.NewTimer(5 * time.Second)
for {
select {
case <-t.C:
process()
t.Stop()
t.Reset(5 * time.Second) // ← unsafe
case <-done:
t.Stop()
return
}
}
The problem is in the Stop / Reset sequence. The Go documentation for Timer.Reset states:
For a Timer created with NewTimer, Reset should be invoked only on stopped or expired timers with drained channels.
And for Stop:
Stop does not close the channel, to prevent a read from the channel succeeding incorrectly.
The key word is does not close the channel. When you call t.Stop(), there is a window in which the runtime’s timer goroutine may have already queued a value on t.C but your goroutine has not yet read it. Stop returns false in this case, but the channel still has a value in it.
When you then call t.Reset(5 * time.Second), the timer starts fresh — but t.C still has the unread value from before. The next select drains that stale value immediately and calls process() at the wrong time.
The Race Window
To make this concrete, here is the sequence of events during the race:
Goroutine (your code) Runtime timer goroutine
───────────────────── ───────────────────────
← fires, sends to t.C (buffered, len=1)
t.Stop() called
→ returns false (already fired)
[value sits in t.C unread]
t.Reset(5s)
[timer is now active again]
select reads t.C
→ reads the STALE value
→ process() called spuriously
The channel is buffered with capacity 1. The stale value is invisible to your code until the next select. Between Stop and Reset, you have not drained the channel, so the stale value persists.
This race is real even when Stop returns false. And because it requires a precise interleaving of the timer goroutine and your goroutine, it almost never appears in tests, which run with deterministic timing under low load.
The Documented Safe Pattern (pre-Go 1.23)
Before Go 1.23, the correct way to reset a timer safely was:
// Stop the timer and drain the channel before resetting.
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(5 * time.Second)
The select with a default branch drains the channel if a value is present, but does not block if it is not. This closes the race window.
However, this pattern has its own subtlety: if your code is already inside a case <-t.C branch (as in the loop above), the channel has already been drained by the select statement that entered the branch. In that case, calling Stop returns false (the timer already fired), but the channel is already empty — so the drain is a no-op. That is fine:
for {
select {
case <-t.C:
process()
// Already drained t.C by entering this branch.
// Stop returns false (already fired), drain is a no-op.
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(5 * time.Second)
case <-done:
t.Stop()
return
}
}
This is correct but visually noisy. The boilerplate is easy to omit under time pressure.
The Go 1.23 Fix
Go 1.23 changed the behaviour of Timer and Ticker channels. From the release notes:
Timer channels created with NewTimer are now synchronous: the garbage collector no longer sends a value on an expired Timer’s channel unless the channel has already been drained. This means that after Timer.Stop returns, the channel is guaranteed to be empty.
In practical terms: on Go 1.23 and later, t.Stop() guarantees the channel is empty after it returns. The drain-before-reset pattern is no longer necessary.
// Go 1.23+: safe without explicit drain
t.Stop()
t.Reset(5 * time.Second)
If your minimum supported Go version is 1.23 or later, you can use this simplified pattern. If you support earlier versions, you need the explicit drain.
Check your go.mod:
go 1.23 ← safe to use simplified Reset
go 1.21 ← must use drain pattern
time.NewTicker as the Cleaner Alternative
For periodic work — where you want to run something every N seconds regardless of how long the work takes — time.NewTicker is almost always simpler and does not have this race:
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
process()
case <-done:
return
}
}
Ticker does not support Reset in the same way. Note the distinction: Timer.Reset has existed for many years (well before Go 1.15), while Ticker.Reset was added in Go 1.15. Go 1.23 then refactored the underlying timer/ticker implementation — synchronous channels, safer Stop/Reset semantics — for both types. A Ticker fires at regular intervals even if the receiver is slow, discarding ticks if the channel is full. If “fire once, then restart the clock after the work is done” is what you need, a Timer with Reset is the right tool. If “fire at a fixed rate” is what you need, use a Ticker.
Where This Bites You in Practice
The timer reset race appears most often in:
- Timeout loops — retry logic that resets a deadline after each attempt
- Heartbeat senders — services that send a ping every N seconds and restart the clock on success
- Session expiry — code that resets an inactivity timer on each user action
- Watchdogs — processes that restart a dependent service if no health check arrives within a window
In all of these, the spurious fire looks like a timeout or expiry that should not have happened. The logs show an event at an unexpected time, with no obvious cause — because the cause was a stale value in a channel from a reset race that happened nanoseconds earlier.
The Mental Model That Prevents the Bug
A stopped timer’s channel may still have a value in it. Before you reset, drain it — unless you are on Go 1.23 or later, or you are already inside the channel’s case branch.
The deeper principle: Stop stopping future fires does not undo a fire that has already happened. The channel is a queue, not a signal. Once a value is in it, it stays there until someone reads it.
Summary
| Go version | After Stop(), channel guaranteed empty? | Safe Reset pattern |
|---|---|---|
| < 1.23 | No | Stop + conditional drain + Reset |
| ≥ 1.23 | Yes | Stop + Reset |
Inside case <-t.C | Already drained by select | Stop + Reset (drain is no-op) |
| Periodic work | N/A | Use time.NewTicker instead |
The timer reset race is the kind of bug that makes you question your understanding of Go’s concurrency model, because the code looks correct and the race is invisible to the race detector. Once you understand that the channel is a queue and Stop only affects future enqueues — not the current contents — the behaviour becomes predictable, and the fix is obvious.
