Go 1.23 made timer channels unbuffered. What Stop and Reset guarantee now, why the old drain line blocks forever, and how to write select loops that never see a stale tick.
· 7 min read

Go Timers and Tickers: Stop, Reset, and the Go 1.23 Channel Changes


Before Go 1.23, channel-based timers commonly needed a stop-and-drain sequence such as if !t.Stop() { <-t.C }. It was easy to get wrong, especially with another receiver or an already-consumed value.

Go 1.23 changed the capacity of timer channels from 1 to 0, and that one line in the release notes retired the pattern. Under the new semantics, Stop can return false after the deadline has passed while guaranteeing that no stale value remains to receive, so the old drain can block forever. Deleting it is a correctness fix.

Here’s what changed, what the Stop and Reset booleans actually promise, and how to shape a select loop so a timer never hands you an event you stopped caring about.

The two Go 1.23 timer changes

The first change is garbage collection. Before Go 1.23, the collector did not recover timers that hadn’t yet expired or been stopped. Unstopped Timers were collected only after they fired; unstopped Tickers were never collected at all. That’s the origin of the reflexive defer t.Stop() on the line after time.NewTimer.

As of Go 1.23, unreferenced timers and tickers become eligible for collection immediately, stopped or not. The time.NewTimer docs say so directly: Stop is no longer necessary to help the garbage collector. You may still want to call it for reasons that have nothing to do with memory, like stopping an AfterFunc callback or shutting off a ticker that another goroutine still holds.

The second change is the channel. Timer and ticker channels are now unbuffered, capacity 0. The guarantee that falls out of that: for any call to Reset or Stop, no stale value prepared before the call will be sent or received after it. The old one-element buffer is exactly why Reset and Stop were so hard to use correctly.

If you’re debugging old code, watch for one visible side effect. len and cap on a timer channel now return 0 instead of 1. Anything that polled the length to guess whether a receive would succeed is broken. Use a non-blocking receive with a default case instead.

In Go 1.23 through Go 1.26, both behaviors were gated on the go line in the main module’s go.mod: an older go line selected the old semantics, and GODEBUG=asynctimerchan=1 could restore them temporarily. Go 1.27 removed that compatibility setting; timer channels are now always synchronous, regardless of the module’s go line.

What Stop and Reset actually return

Timer.Stop returns true if the call stopped the timer, false if the timer had already expired or been stopped. Timer.Reset returns true if the timer had been active, false if it had expired or been stopped. Those signatures didn’t change. What changed is how much you can lean on them.

For a chan-based timer from NewTimer, Go 1.23 attaches real guarantees. After Stop returns, any receive from t.C blocks rather than yielding a stale time, and if you haven’t already received from t.C while the timer is running, Stop returns true. After Reset returns, any receive from t.C is guaranteed not to deliver a time from the previous timer settings, with the same condition attached to the return value.

Before Go 1.23 the drain was mandatory: if !t.Stop() { <-t.C } for Stop, and stop-then-drain before every Reset. On a Go 1.23 module that code is dead weight at best and a deadlock at worst.

A reusable timer in a select loop

Allocating a fresh time.NewTimer each iteration works fine. Reusing one is cheap, and it makes the Reset semantics concrete. Here’s an illustrative idle-timeout loop assembled from the documented APIs:

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// consume reads from work until it goes idle for more than idle,
// or until ctx is cancelled.
func consume(ctx context.Context, work <-chan string, idle time.Duration) error {
	t := time.NewTimer(idle)
	defer t.Stop() // not needed for GC in Go 1.23+, but harmless and explicit

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()

		case item, ok := <-work:
			if !ok {
				return nil
			}
			fmt.Println("got:", item)

			// Stop before Reset is no longer required in Go 1.23+.
			// Reset alone guarantees no pre-Reset value arrives on t.C.
			t.Reset(idle)

		case <-t.C:
			return errors.New("idle timeout")
		}
	}
}

The line that matters is t.Reset(idle) with no drain in front of it. Under Go 1.23 semantics, the next <-t.C cannot deliver the time value from the old deadline. Under the old semantics it could, and the loop would report an idle timeout for a channel that was busy.

Tempted to write something that’s correct under both rule sets? Don’t bother. Correct-under-both means keeping the drain, and the drain blocks under the new rules unless you wrap it in a non-blocking receive. Bump the go.mod line instead.

For how these cases compose with cancellation and fan-in, see Go select statements: timeouts, cancellation, and fan-in patterns.

Tickers: Stop does not close the channel

time.NewTicker panics if the duration isn’t greater than zero, and Ticker.Reset panics on the same condition. Validate any interval that arrives from config before it reaches either one. A ticker interval of zero from a misparsed YAML field takes down the process.

The ticker adjusts the interval or drops ticks to make up for slow receivers. You will never get a backlog delivered in a burst. A slow handler produces fewer ticks, not a pile-up.

Stop turns the ticker off but leaves the channel open. The docs give two reasons: so Reset can revive it later, and so a concurrent goroutine reading the channel doesn’t see an erroneous “tick” from a closed-channel receive. That second one is the real constraint, and it dictates how you terminate a ticker loop. You need a separate signal:

func poll(ctx context.Context, every time.Duration, fn func()) {
	tk := time.NewTicker(every)
	defer tk.Stop() // stop ticking for anyone else holding tk

	for {
		select {
		case <-ctx.Done():
			return
		case <-tk.C:
			fn()
		}
	}
}

ctx.Done() is the exit path. Range over tk.C instead and the loop blocks forever once the work is done, because nothing ever closes the channel. The time.NewTicker package example uses a plain done channel for the same reason; a context.Context gives you the same shape plus cancellation that propagates. If contexts are new to you, Context in Go covers the basics.

AfterFunc has different rules

time.AfterFunc(d, f) runs f in its own goroutine after d elapses. The returned *Timer has a nil C field. No channel means the synchronous-channel guarantees above do not apply.

The booleans mean something else here. If Stop returns false, the timer has already expired or been stopped; if it expired, f has been started and Stop does not wait for it to finish. Reset returns true when it reschedules a still-active run and false when it schedules f again after expiry or stop. When Reset follows expiry, it neither waits for the prior f to complete nor guarantees the new goroutine won’t run concurrently with the old one.

So if your callback touches shared state, Stop is not shutdown. The docs are explicit that you have to coordinate with f yourself. For a one-shot callback, a completion channel keeps the two cases separate:

done := make(chan struct{})
t := time.AfterFunc(after, func() {
	defer close(done)
	fn()
})

// Stop returning false means fn has started (or already returned),
// so wait for the callback's completion signal.
if !t.Stop() {
	<-done
}

The channel is that explicit coordination. A true result from Stop means the callback was prevented, so no receive is needed. A false result means the callback has started or completed, and receiving from done waits for it. Preventing a future callback and waiting for an existing callback are two different questions.

When to reach for context instead

For a single deadline on a single operation, a raw timer is usually the wrong tool. context.WithTimeout gives you a cancellable deadline that travels down the call stack, and the docs are explicit that cancelling releases the associated resources, so defer cancel() belongs on the next line:

func slowOperationWithTimeout(ctx context.Context) (Result, error) {
	ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
	defer cancel() // releases resources if slowOperation finishes early
	return slowOperation(ctx)
}

Use time.NewTimer when the deadline lives inside one select and needs resetting, like the idle-timeout loop above. Use context.WithTimeout when the deadline has to cross an API boundary.

Both tools fail the same way when you get them wrong: a goroutine parked on a channel nobody will ever send to. That shape has plenty of cousins, catalogued in common goroutine leaks in Go. And if you want the rest of the time API, the time package guide covers formatting, parsing, and comparison.