Timer code in Go has accumulated a surprising amount of folklore. Older examples warn that time.After leaks resources, insist that every stopped timer channel must be drained, and wrap Timer.Reset in careful stop-and-drain sequences.

Those rules were important for older Go programs. They are not all current rules.

Go 1.23 changed the implementation and guarantees of channel-based timers. Unreferenced timers can now be garbage collected before they fire, and timer channels use synchronous semantics that prevent stale values after Stop or Reset returns. The result is simpler timer code—but only when the program is actually using the new semantics.

Start with the Go 1.23 boundary

The relevant APIs did not change shape:

timer := time.NewTimer(5 * time.Second)

select {
case <-timer.C:
    // timeout
case result := <-work:
    _ = result
}

What changed is their behavior.

For channel-based timers under the Go 1.23 semantics:

  • an unreferenced timer can be garbage collected even if it has not fired or been stopped;
  • the timer channel is synchronous rather than a one-element buffered channel;
  • after Timer.Stop returns, a later receive cannot observe a stale value from the stopped configuration;
  • after Timer.Reset returns, a later receive cannot observe a stale value from the previous configuration.

These guarantees remove two old sources of complexity: keeping timers alive until expiration for garbage collection reasons and manually draining stale timer values.

There is an important compatibility detail. The new implementation is selected for programs whose main module declares Go 1.23 or later. Go also provides the asynctimerchan GODEBUG setting for controlling the compatibility behavior. Therefore, the toolchain version alone is not enough to tell you which assumptions are valid.

time.After is no longer a garbage-collection trap

A timeout in a select is often clearest with time.After:

select {
case item := <-items:
    handle(item)
case <-time.After(2 * time.Second):
    return errors.New("timed out waiting for item")
}

time.After(d) is equivalent to receiving from time.NewTimer(d).C.

Before Go 1.23, abandoning that channel before the timer fired meant the underlying timer could not be garbage collected until expiration. In a hot loop with long durations, repeatedly creating and abandoning timers could retain timer resources unnecessarily. That history produced the common advice to avoid time.After when efficiency mattered.

Under Go 1.23 timer semantics, an unreferenced, unstopped timer can be garbage collected. The standard-library documentation explicitly says there is no longer a reason to prefer NewTimer merely for garbage-collection purposes when After otherwise fits the job.

That does not make allocation cost irrelevant. A loop that creates a fresh timer on every iteration still creates fresh timer objects. If one logical timer is repeatedly rescheduled, reusing a Timer can still be a sensible design. The distinction is important: reuse can be an efficiency choice without being a correctness requirement for garbage collection.

Stop still has a purpose

The garbage-collection change does not make Timer.Stop useless.

Use Stop when the program wants to prevent a timer from firing because the operation completed first:

timer := time.NewTimer(10 * time.Second)
defer timer.Stop()

select {
case result := <-results:
    return result, nil
case <-timer.C:
    return Result{}, errors.New("operation timed out")
}

The deferred Stop is not required to make an unreachable timer collectible under Go 1.23 semantics. It is still useful lifecycle documentation, and in paths where the timer remains reachable it explicitly prevents a future firing that the program no longer wants.

Stop returns a boolean. For a channel-based timer, it reports whether the call stopped an active timer. A false result means the timer had already expired or had already been stopped.

Under the new semantics, you do not need to interpret false as an instruction to drain a stale value from timer.C.

The old stop-and-drain pattern belongs to older semantics

Older Go code commonly contains a helper like this:

if !timer.Stop() {
    <-timer.C
}
timer.Reset(timeout)

That pattern addressed the old buffered timer channel. A timer could expire and place a value in its one-element channel buffer. Resetting without accounting for that value could allow a later receive to consume the old timeout rather than the newly scheduled one.

As of Go 1.23, a channel-based timer has stronger guarantees. After Reset returns, a receive from timer.C will not receive a value corresponding to the previous timer settings. After Stop returns, a receive will not receive an old value from before the stop.

For code that requires Go 1.23 timer semantics, this is enough:

timer.Reset(timeout)

Do not mechanically add a drain because an old blog post or code review checklist says timers must always be drained. That can make modern code harder to read and can be actively dangerous if the receive blocks because there is no stale value to consume.

If a library or application must support pre-1.23 timer semantics, keep the compatibility requirement explicit and use the older safe pattern appropriate to that supported environment. Mixing rules from two timer models without stating the version boundary is where confusion starts.

Reuse one timer for an inactivity deadline

A useful reason to call Reset is an inactivity timeout. Suppose each received message should extend a deadline:

func consume(messages <-chan Message, idle time.Duration) error {
    timer := time.NewTimer(idle)
    defer timer.Stop()

    for {
        select {
        case msg, ok := <-messages:
            if !ok {
                return nil
            }

            if err := process(msg); err != nil {
                return err
            }

            timer.Reset(idle)

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

With Go 1.23 timer semantics, resetting the timer after processing a message does not require a stop-and-drain ceremony to protect the next receive from an old timer value.

The ownership model is still important. This loop is easy to reason about because one goroutine owns the timer and receives from its channel. Sharing a timer across goroutines while several goroutines call Reset, Stop, or receive from C creates a much harder synchronization problem. Stronger channel semantics do not replace application-level ownership.

Reset schedules from the call, not from the old deadline

Reset(d) changes the timer so that it expires after duration d. Think of the call as establishing a new timer configuration at that point in execution.

That matters for loops where processing itself takes time:

case job := <-jobs:
    if err := process(job); err != nil {
        return err
    }
    timer.Reset(idleTimeout)

Here the idle interval starts after processing completes. If the intended policy is instead “the next message must arrive within this duration of the previous arrival,” reset before processing or record an explicit deadline and compute the remaining duration.

Timer APIs provide mechanism; they do not decide what event your timeout is measured from.

Do not use len(timer.C) to ask whether a timer fired

Old timer channels had capacity one, so some code inspected their length:

if len(timer.C) == 1 {
    <-timer.C
}

That is not a sound synchronization technique in general, because channel state can change immediately after it is observed. It is also specifically incompatible with the Go 1.23 timer implementation, where timer channels report capacity and length zero.

If you genuinely need a non-blocking receive, express that operation directly:

select {
case <-timer.C:
    // timer value received
 default:
    // no timer value available now
}

Usually you can avoid polling altogether and structure the surrounding operation as a select that includes the timer channel alongside the work it limits.

Very short timers can expose select assumptions

The Go 1.23 implementation also makes very short timers become ready more promptly. That can reveal tests or code that accidentally depended on scheduling delay.

Consider a closed channel competing with an almost-immediate timer:

done := make(chan struct{})
close(done)

select {
case <-done:
    fmt.Println("done")
case <-time.After(time.Nanosecond):
    fmt.Println("timeout")
}

If both cases are ready when select makes its choice, Go may choose either ready case. Code should not assume the already-closed channel necessarily wins just because the timer was created immediately before the select.

This matters most in tests that use zero or tiny durations. A test that requires deterministic ordering should create deterministic synchronization instead of relying on one ready operation being observed before another.

Timer.Reset is different for AfterFunc

The stronger channel guarantees apply to channel-based timers. A timer returned by time.AfterFunc has different behavior because its C field is not used.

t := time.AfterFunc(time.Second, func() {
    refresh()
})

For an AfterFunc timer, Reset can schedule the function again after an earlier execution has begun. A false return from Reset does not mean the previous function invocation has completed, and a newly scheduled invocation can overlap the previous one.

Likewise, Stop does not wait for an already-started function to finish.

If callback executions must not overlap, coordinate that requirement explicitly with the callback. Do not transfer reasoning about the synchronous Timer.C channel to AfterFunc, because there is no timer channel participating in that callback lifecycle.

Keep context deadlines and timers at the right abstraction level

If an API already accepts context.Context, prefer expressing request cancellation and deadlines through the context rather than adding a separate timer around every call:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

return client.Do(ctx)

A direct Timer is useful when the timeout belongs to a local state machine: inactivity, batching, retries, delayed transitions, or coordination among channels. A context is often better when the deadline belongs to an operation that crosses API boundaries.

Using the right abstraction avoids two independent cancellation mechanisms racing to describe the same lifetime.

Test timer behavior without fragile sleeps

Timer tests become unreliable when they assert exact wall-clock timing. Schedulers, loaded CI machines, and platform timer resolution all introduce variation.

Prefer synchronization around the event the timer controls. For example, to verify that resetting an active timer postpones its receive:

func TestResetPostponesTimer(t *testing.T) {
    timer := time.NewTimer(time.Hour)
    defer timer.Stop()

    if !timer.Reset(time.Hour) {
        t.Fatal("expected active timer")
    }

    select {
    case <-timer.C:
        t.Fatal("timer fired unexpectedly")
    default:
    }
}

For code with substantial timer-driven behavior, consider putting time creation behind a small interface so tests can use a controllable clock. The goal is not to reproduce the runtime’s timer implementation; it is to make the application’s state transitions deterministic.

Also run compatibility-sensitive tests with the module’s actual go directive and intended GODEBUG configuration. A test run under a newer toolchain does not automatically prove that the program is using the newer timer-channel semantics.

The practical rules

For programs using Go 1.23 timer semantics, timer code is simpler than much older advice suggests.

Use time.After when it is the clearest expression of a one-off timeout; an abandoned timer no longer has to remain until expiration merely for garbage-collection reasons. Use NewTimer when you need to stop or reset a timer as part of the program’s lifecycle. After Stop or Reset returns, do not add manual channel drains to defend against stale values from the old timer configuration.

At the same time, keep the compatibility boundary visible. Pre-Go 1.23 timer channels had different guarantees, and the compatibility behavior can still be selected. Timer ownership, timeout policy, and callback synchronization also remain application concerns.

The useful upgrade is not simply deleting every call to Stop. It is replacing inherited timer folklore with the guarantees of the timer model your program actually runs.