skip to content

Go 1.23 rewrote the runtime's timers: what changed observably for time.Timer and time.Ticker?

level: seniorimportance: should knowfreq 30%

answer

  1. two changes, not one
  2. who was holding the reference before
  3. capacity one became capacity zero
  4. the drain idiom retires
  5. the GODEBUG hatch had an expiry date

basics

~20 s

Two things. Unreferenced timers and tickers became ordinary garbage, collectable even if Stop was never called. And their channels became unbuffered, so Stop and Reset now guarantee no value prepared before the call is delivered after it.

solid answer

~50 s

Before Go 1.23 the runtime's timer machinery held a reference to every pending timer, so a `time.Ticker` you forgot to `Stop` stayed alive and kept firing forever, and a `time.After` timer stayed alive until its deadline even after the select that created it had returned. From 1.23 timers are ordinary heap objects: once your program no longer refers to one, it is collectable even without `Stop`. The second change is delivery. Timer channels became unbuffered, capacity zero, and the runtime now guarantees that after `Stop` or `Reset` returns, no value prepared before that call will be delivered. That kills the stale-tick race that made the guarded drain idiom necessary. The old asynchronous behaviour survived for a while behind `GODEBUG=asynctimerchan=1`, defaulted on for modules whose `go` directive predated 1.23; Go 1.27 removed that setting, so time-package channels are synchronous unconditionally.

code

go · 6 lines
go
t := time.NewTimer(200 * time.Millisecond)
if !t.Stop() {
	<-t.C // needed before Go 1.23: a fired tick could still be buffered
}
t.Reset(200 * time.Millisecond)
// Go 1.23+: Stop and Reset guarantee no pre-call value is delivered, so the drain is unnecessary

go deeper

for a junior

Know that on current Go you no longer need to drain a timer's channel after stopping it, and that forgetting to stop a ticker is a bug worth fixing even though the runtime is more forgiving than it used to be.

for a middle

Explain the mechanism of the old stale tick: a capacity-one channel plus a non-blocking send at expiry, so a value could already be buffered by the time Stop was called. Then say what unbuffered channels guarantee instead.

for a senior

Demonstrate upgrade judgment. Name the code shapes to audit before moving a service to a newer toolchain, and be able to say why a GODEBUG opt-out is a scheduled debt rather than a fix.

for a principal

Own the policy: track which GODEBUG hatches your services rely on and when each one expires, so a language upgrade never turns into a surprise behaviour change discovered in production.

## What the old implementation did Before Go 1.23, a `time.Timer` or `time.Ticker` was backed by a channel with **capacity one**. When the deadline passed, the runtime performed a non-blocking send into that buffer. If nobody was receiving, the value simply sat there. Two problems fall straight out of that. **A stale value can outlive the decision to stop.** Suppose a retry-and-backoff layer keeps one timer and resets it per attempt. The timer expires, the runtime drops a time into the buffer, and only then does your code call `Stop`. `Stop` returns `false` — too late — and the buffered value is still sitting there, ready to be received by the *next* wait as if that wait had timed out immediately. Hence the well-known guarded drain: ```go if !t.Stop() { <-t.C } t.Reset(d) ``` which is easy to get wrong (drain unconditionally and you can block; skip it and you inherit a phantom tick), and impossible to get right at all if another goroutine might also be receiving from that channel. **A timer could not be collected.** The runtime's timer heap referred to the timer, so it was reachable no matter what your program did with it. A `time.Ticker` that was never stopped therefore never went away: it kept re-arming, kept waking a thread, and kept its channel alive — the classic ticker leak. Likewise `time.After(time.Hour)` inside a select that returns after a millisecond left the runtime holding that timer, and its channel, for the full hour. ## What Go 1.23 changed **Timers became ordinary garbage-collected objects.** The runtime no longer keeps a timer reachable purely because it is pending. A `Timer` or `Ticker` that your program no longer refers to is eligible for collection immediately, *even if `Stop` was never called*. The un-stopped ticker leak and the long-lived `time.After` retention both disappear on 1.23 or later. **Timer channels became unbuffered — capacity zero.** `cap(t.C)` reports 0. The delivery path is special-cased in the runtime rather than being a plain buffered send, and it carries an explicit guarantee: *for any call to `Stop` or `Reset`, no value prepared before that call is sent or received after it*. A receive that happens after the timer has fired still observes the fire time; what cannot happen any more is a value from before a `Stop` or `Reset` leaking into the next wait. The practical effect is that the guarded drain becomes unnecessary. `Stop` then `Reset` is enough. And code that drained *unconditionally* after `Stop` is now simply wrong — with no value pending, that receive blocks until the next firing, which for a stopped `Timer` never comes. ## The compatibility hatch, and its expiry Go's GODEBUG mechanism defaults settings according to the `go` directive in your `go.mod`, so a module still declaring an older Go kept the asynchronous behaviour automatically, and any module could opt back in with `GODEBUG=asynctimerchan=1`. That hatch is gone: in **Go 1.27** the setting was removed and time-package channels are always synchronous, regardless of the language version a module declares. That is the part worth carrying into an upgrade plan. A GODEBUG is a *deferral with a deadline*, typically a couple of years wide, not a permanent opt-out. If a service was papering over the 1.23 change with `asynctimerchan=1`, the bill arrives at the 1.27 toolchain, and it arrives all at once. ## What to audit when you upgrade - Any code that receives from a timer channel **after** calling `Stop` — especially unconditional drains, and drains shared between goroutines. - Any code whose correctness depends on a tick still being buffered after a `Reset`. - Tests that assert on `cap` of a timer channel, or that assume a tick is retrievable long after the fact. - Ticker lifetimes: on 1.23+ a forgotten `Stop` is no longer an unbounded leak, but it is still a bug worth fixing, because the ticker keeps firing for as long as anything refers to it. ## What to say in an interview "1.23 did two things: timers became normal GC-able objects, so an unreferenced un-stopped ticker no longer leaks, and timer channels became unbuffered with a guarantee that `Stop` and `Reset` discard anything prepared before the call. That retires the drain idiom. The old behaviour lived behind `GODEBUG=asynctimerchan=1` until 1.27 removed it."

  • On Go 1.23 or later, should you still write the guarded drain after calling Timer.Stop?
    No. The runtime guarantees that no value prepared before a Stop or Reset call is delivered after it, so the drain buys nothing. Delete it once every module in play declares go 1.23 or later. An unconditional drain is worse than useless: with nothing pending it blocks until the next firing, which for a stopped timer never arrives.
  • What did GODEBUG=asynctimerchan=1 do, and why can you no longer rely on it?
    It restored the pre-1.23 buffered, asynchronous timer channels, and it defaulted on for modules whose go directive predated 1.23 so upgrades would not break. Go 1.27 removed the setting: time-package channels are synchronous unconditionally now. A GODEBUG buys you a few releases to fix code, not a permanent opt-out.
  • Before Go 1.23, why did a time.After inside a select hold memory even after the other case won?
    The runtime's timer machinery kept a reference to the pending timer, and through it the channel, until the deadline actually passed. A one-hour timeout on a call that returned in a millisecond therefore stayed resident for an hour. From 1.23 an unreferenced timer is collectable immediately, whether or not it has fired or been stopped.
  • Does a forgotten Ticker.Stop still matter on modern Go?
    Yes, though the failure mode is smaller. The ticker is collectable once nothing refers to it, so it is no longer an unbounded leak, but for as long as anything does refer to it, it keeps firing and keeps waking threads on schedule. Stopping it remains the correct lifecycle discipline.

saying these in an interview costs you the question

  • Still teaches the guarded drain as mandatory on current Go
  • Says timer channels have capacity one in current Go
  • Claims an un-stopped ticker is collectable in every Go version
  • Assumes a GODEBUG compatibility setting lasts indefinitely
  • Confuses the timer rewrite with the earlier move to per-P heaps