Timers, Tickers, and time.After Pitfalls

Updated

September 8, 2026

Timers, Tickers, and time.After Pitfalls

Overview

Timers are a frequent source of subtle leaks and wakeups in hot paths. The runtime keeps a timer heap (per-P structures in modern Go). Stopping and resetting correctly matters as much as creating timers.

Diagram: Timer heap cost

flow:
  [Heap]
       |
       v
  [GC]

APIs

API Use
time.NewTimer One-shot; must Stop/Reset carefully
time.NewTicker Periodic; always Stop
time.After Convenient; allocates a new timer each call
time.AfterFunc Fire a function on a timer thread-ish path

The Classic Pitfall

// BAD in a busy loop / per-request select
select {
case <-ch:
case <-time.After(time.Second): // new timer every iteration
}

Each time.After creates a timer that lives until it fires (or is GC’d after fire). Under load this is allocation + timer-heap churn.

Prefer:

t := time.NewTimer(time.Second)
defer t.Stop()
select {
case <-ch:
    if !t.Stop() {
        select {
        case <-t.C:
        default:
        }
    }
case <-t.C:
}

Or reuse with Reset after successful Stop/drain per current docs for your Go version.

Tickers

tk := time.NewTicker(100 * time.Millisecond)
defer tk.Stop()
for {
    select {
    case <-ctx.Done():
        return
    case <-tk.C:
        // work
    }
}

A stopped ticker is not restarted with Reset in older Go the same way as timers — check version docs.

Runtime View

Timers are not free threads. They are heap entries processed by the runtime; expired timers make Gs runnable. Huge numbers of far-future timers cost memory; huge numbers of near-term timers cost CPU in the timer processor.

Context Timeouts

context.WithTimeout uses timers under the hood. Prefer one timeout at the edge and derive children, rather than stacking independent After calls at every layer without budget math.

Experiment

go test -bench=. -benchmem
package timers_test

import (
    "testing"
    "time"
)

func BenchmarkTimeAfter(b *testing.B) {
    ch := make(chan struct{})
    for i := 0; i < b.N; i++ {
        select {
        case <-ch:
        case <-time.After(time.Hour):
        default:
        }
    }
}

func BenchmarkTimerReuse(b *testing.B) {
    t := time.NewTimer(time.Hour)
    defer t.Stop()
    ch := make(chan struct{})
    for i := 0; i < b.N; i++ {
        if !t.Stop() {
            select {
            case <-t.C:
            default:
            }
        }
        t.Reset(time.Hour)
        select {
        case <-ch:
        case <-t.C:
        default:
        }
    }
}

What to notice: time.After in a loop allocates; reuse patterns dominate under -benchmem.

Try next: Find time.After in a hot select in your codebase with rg 'time\\.After'.