skip to content

Do you still need to drain a time.Timer's channel before calling Reset, and what breaks if you do?

level: seniorimportance: should knowfreq 42%

answer

  1. the ritual line everyone copied
  2. why was there anything to drain
  3. the channel used to hold one value
  4. capacity zero changes everything
  5. Stop discards it, so the receive waits forever

basics

~20 s

No. Since Go 1.23 a timer's channel is unbuffered and Go guarantees no value prepared before a Stop or Reset call is delivered after it. The old drain idiom, receiving whenever Stop returns false, can now block forever.

solid answer

~50 s

Not any more. Before Go 1.23, a `time.Timer`'s channel had capacity one, so a timer that fired left a stale value sitting in the buffer; if you re-armed the timer without removing it, the next receive fired instantly with an old timestamp. That is why the ritual `if !t.Stop() { <-t.C }` before `t.Reset(d)` existed — and it was always fragile, because if another goroutine had already consumed the value the drain blocked. Go 1.23 made timer channels unbuffered and guarantees that no value prepared before a `Stop` or `Reset` call can be sent or received after that call returns. So the correct modern code is just `t.Stop(); t.Reset(d)`, and the legacy drain is now actively dangerous: when `Stop` returns false because the timer fired and nobody read it, that value has been discarded, and the `<-t.C` blocks forever. Go 1.27 removed the `asynctimerchan` GODEBUG escape hatch, so the synchronous behaviour is the only behaviour.

code

go · 4 lines
go
if !t.Stop() {
	<-t.C // the pending value was already discarded by Stop
}
t.Reset(interval)

go deeper

for a junior

Know that a timer can be re-armed with Reset and stopped with Stop, and that on current Go you do not need any extra ceremony between them. The historical drain line is not something you should be writing.

for a middle

Be able to explain the mechanics: the channel used to have capacity one so a fired value could linger, and Go 1.23 made it unbuffered with a guarantee that no value prepared before a Stop or Reset survives the call.

for a senior

Show that you would catch the legacy drain in review and explain the failure precisely — Stop discards the pending value, so the receive blocks forever — and that you know the GODEBUG gate tied the old behaviour to the module's go line.

for a principal

Own the migration posture: how a team finds and fixes idioms that silently change meaning across toolchain versions, and how much you invest in an audit pass versus letting a subtle blocking bug surface in production.

## The problem the drain idiom solved Up to Go 1.22, the channel inside a `time.Timer` or `time.Ticker` had capacity one. When the timer fired, the runtime performed a non-blocking send: if a receiver was waiting, it got the value; if not, the value sat in the buffer until someone took it. That buffer is what made re-arming a timer subtle. Consider a loop that arms an idle deadline each pass. If the other `select` case wins, the timer may nevertheless have fired a microsecond later and parked a `time.Time` in the buffer. Call `Reset` and the very next receive on `t.C` returns immediately with that stale value, and your deadline appears to expire instantly. Hence the protocol everyone copied: ```go if !t.Stop() { <-t.C } t.Reset(interval) ``` `Stop` returns `false` when the timer had already fired or was already stopped, which was taken as "a value might be waiting — remove it". It was never quite right. If a different goroutine had already received that value, or if the timer had been stopped earlier without firing, there was nothing to drain and the receive blocked. Getting it correct in the presence of more than one goroutine was, in practice, not possible with the API as it stood. ## What Go 1.23 changed Go 1.23 made timer and ticker channels **unbuffered** — capacity zero — and delivery synchronous. The guarantee that came with it is the one to memorise: for any call to `Stop` or `Reset`, no value prepared before that call will be sent or received after it. In other words, after `Stop` returns, a stale tick cannot reach you; after `Reset` returns, the only value you can ever receive is one produced by the new deadline. So the drain is unnecessary. And worse than unnecessary: in exactly the situation it was written for — the timer fired, nobody received — `Stop` returns `false`, the pending value has been discarded by the `Stop` itself, and the following `<-t.C` waits for a send that will never come. Legacy code carried forward without review can deadlock a goroutine on a machine running current Go. The modern forms are plain: ```go t.Stop() t.Reset(interval) ``` and, when a single goroutine owns the timer and simply wants the next deadline, `t.Reset(interval)` on its own. ## The compatibility mechanics Go takes backward compatibility seriously enough that this behaviour change was gated. In Go 1.23 through 1.26 the old asynchronous, buffered behaviour was still available as `GODEBUG=asynctimerchan=1`, and — because GODEBUG defaults follow the `go` line in the main module's `go.mod` — a module still declaring an older language version kept the old behaviour even when built with a newer toolchain. That is worth knowing when you are diagnosing a timer that behaves differently in two services built from the same toolchain: check their `go` lines. Go 1.27 removed that setting. Timer channels are now always synchronous, with no way back. ## Reading Stop's return value today `Stop` still returns a bool: `true` if it stopped a timer that had not yet fired, `false` if the timer had already fired or had already been stopped. What changed is what you may conclude from it. It is a statement about the timer's schedule, not an indicator that a value is parked in a buffer waiting for you — there is no buffer. Code that branches on the bool in order to decide whether to receive is reasoning about a data structure that no longer exists. Two related facts that survive every version: `Stop` does not close the channel — nothing closes a timer channel, because a close would look to receivers like a tick that never happened — and `Reset` on an already-fired timer is fine, it simply schedules the next firing. ## How to talk about this in an interview The strong answer has three beats. State the modern rule (no drain; `Stop` then `Reset`). Explain why the ritual existed (the capacity-one buffer and the stale value). Then name the hazard: the idiom does not merely waste a line now, it can block forever, so porting old timer code to a current toolchain deserves a deliberate pass rather than a compile-and-ship.

  • What does the bool returned by Timer.Stop mean on current Go?
    It reports the schedule, not the channel: `true` if `Stop` cancelled a timer that had not yet fired, `false` if the timer had already fired or was already stopped. It is no longer a hint that a value is waiting to be received, because the channel has capacity zero and holds nothing. Branching on it to decide whether to drain is reasoning about a buffer that no longer exists.
  • Could a service still see the old buffered timer behaviour?
    Between Go 1.23 and 1.26, yes — `GODEBUG=asynctimerchan=1` restored it, and because GODEBUG defaults follow the `go` line in the main module's go.mod, a module declaring an older language version kept the old behaviour on a newer toolchain. Go 1.27 removed the setting entirely, so timer channels are always synchronous there.
  • Does Stop or Reset close the timer's channel?
    Neither does. Nothing in the `time` package closes a timer or ticker channel, deliberately: a closed channel yields the zero `time.Time` immediately and forever, which a receiver would read as a tick that never happened. So `for range t.C` never terminates, and "the timer is finished" has to come from your own state rather than from the channel.
  • Is it safe for several goroutines to share one Timer and call Reset?
    The Go 1.23 guarantee removes stale deliveries, which is the sharpest edge, but a timer is still not a coordination primitive. Give one goroutine ownership of `Stop` and `Reset` and let others learn about expiry through your own channel or state. Multi-owner reset logic is hard to reason about and was outright unfixable before the change.

saying these in an interview costs you the question

  • Insists you must always drain the channel before calling Reset
  • Says Stop returning false means a value is waiting in the channel
  • Claims Stop closes the channel so a receive returns at once
  • Believes a stale tick can still arrive after Reset returns
  • Thinks the timer channel is buffered with capacity one on current Go