Tickers in Go and How to Stop Them Without Leaking
How time.Ticker works in Go: Stop does not close the channel, slow consumers lose ticks, Reset changes the period, and what Go 1.23 changed for collection.

A time.Ticker is a clock that ticks on a channel. It is the tool for periodic work in Go: poll a service every ten seconds, flush a buffer every second, report metrics every minute. It is also a quiet source of leaks, hangs and subtle timing bugs, because several of its behaviors are not what the name suggests: it does not close its channel when stopped, it does not queue ticks for a slow reader, and its lifecycle is separate from the goroutine that uses it.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article covers those behaviors one by one, with a demonstration of each, then the patterns that use a ticker correctly. It relates to select, periodic tasks and the Go 1.23 timer changes. Everything ran on Go 1.25.6.
The basic loop
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
done := time.After(110 * time.Millisecond)
ticks := 0
for {
select {
case <-ticker.C:
ticks++
case <-done:
fmt.Println("ticks in 110ms at 20ms intervals:", ticks)
return
}
}
}ticks in 110ms at 20ms intervals: 5Five ticks arrive in 110 ms at a 20 ms interval (at 20, 40, 60, 80 and 100 ms). The shape is the standard one: create the ticker, defer ticker.Stop() on the next line, and loop in a select that has an exit. The first tick comes after one interval, not immediately; if you want an immediate first run, call the work once before the loop.
Stop does not close the channel
This surprises almost everyone. Stop turns the ticker off, so no more values will be sent, but it does not close ticker.C. A goroutine that ranges over the channel never learns the ticker stopped:
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(10 * time.Millisecond)
ticker.Stop()
select {
case _, ok := <-ticker.C:
fmt.Println("received after Stop, open:", ok)
case <-time.After(50 * time.Millisecond):
fmt.Println("nothing arrives after Stop, and the channel is not closed either")
}
}nothing arrives after Stop, and the channel is not closed eitherAfter Stop, nothing arrives and the channel stays open, so a for range ticker.C loop would block forever on the next receive and leak its goroutine. The documentation says this directly: Stop does not close the channel, to prevent a concurrent goroutine reading from the channel from seeing an erroneous "tick". The consequence for design is that the loop must have another way to end, a context or a done channel, selected alongside the ticker, and Stop is cleanup, not the stop signal (stopping a goroutine).
for {
select {
case <-ticker.C:
work()
case <-ctx.Done():
return // the loop ends; the deferred Stop releases the ticker
}
}Slow consumers lose ticks
A ticker has a channel with room for one tick (before Go 1.23; since then the channel is unbuffered but behaves the same for this purpose). If a tick fires while the previous one has not been received, the new tick is dropped, not queued. A loop that takes longer than the interval does not get a backlog of ticks to catch up on:
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
start := time.Now()
received := 0
for time.Since(start) < 200*time.Millisecond {
<-ticker.C
received++
time.Sleep(35 * time.Millisecond) // work that takes 3.5 intervals
}
fmt.Println("elapsed 200ms at 10ms ticks: about 20 possible, received:", received)
}elapsed 200ms at 10ms ticks: about 20 possible, received: 6In 200 ms there were twenty ticks, and the loop received about six, one per iteration of 35 ms of work. The rest were discarded. That is usually what you want (a periodic job that runs late should not run back-to-back to catch up), and it means a ticker is not a counter. To measure elapsed time, compare timestamps; to run a job exactly N times per hour, count your own runs or use the tick's time value (t := <-ticker.C), which is the moment the tick was sent, not when you read it (periodic jobs without drift).
Reset
ticker.Reset(d) changes the interval of a ticker that is already running; the next tick comes after the new duration. It is how you build adjustable intervals, such as polling that slows down when nothing changes and speeds up when something does:
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
interval := 10 * time.Millisecond
start := time.Now()
for i := 1; i <= 4; i++ {
<-ticker.C
fmt.Printf("tick %d after about %v\n", i, time.Since(start).Round(10*time.Millisecond))
interval *= 2
ticker.Reset(interval) // back off: 20ms, 40ms, 80ms
}
}tick 1 after about 10ms
tick 2 after about 30ms
tick 3 after about 70ms
tick 4 after about 150msThe ticks arrive at roughly 10, 30, 70 and 150 ms, because each one doubles the wait before the next. Reset panics if the duration is not positive, like NewTicker. It is the cleaner alternative to stopping a ticker and creating a new one.
Leaks, before and after Go 1.23
A ticker that is never stopped keeps sending ticks. Before Go 1.23, the runtime also kept the ticker alive forever, so creating tickers in a loop without stopping them leaked each one. Go 1.23 changed that: a ticker that nothing refers to any more becomes eligible for garbage collection immediately, even if Stop was not called. For modules whose go.mod says 1.23 or later, forgetting Stop on a ticker you drop does not leak the ticker (the timer changes in detail).
That is not a licence to skip Stop. A ticker that a running goroutine still references is not unreferenced, so a loop that is left running keeps its ticker alive and firing. The leak that remains is the goroutine, not the ticker, and a stuck loop with a live ticker wakes the runtime on every interval for no purpose. defer ticker.Stop() costs nothing and states the intent, and it is still the right habit, especially for code that may run on older module versions. time.Tick is the one-line version that returns only the channel and so cannot be stopped; before 1.23 it leaked by design and was documented as suitable only for programs that never need to stop the ticker. It is fine for the main loop of a program that runs until exit, and nowhere else.
A ticker owned by a type
The common production shape is a component that runs a periodic job in the background and can be stopped:
package main
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
// Reporter calls fn every interval until Stop is called or ctx ends.
type Reporter struct {
cancel context.CancelFunc
wg sync.WaitGroup
}
func StartReporter(ctx context.Context, interval time.Duration, fn func()) *Reporter {
ctx, cancel := context.WithCancel(ctx)
r := &Reporter{cancel: cancel}
r.wg.Go(func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fn()
case <-ctx.Done():
return
}
}
})
return r
}
// Stop ends the reporter and waits for its goroutine to exit.
func (r *Reporter) Stop() {
r.cancel()
r.wg.Wait()
}
func main() {
var calls atomic.Int64
r := StartReporter(context.Background(), 10*time.Millisecond, func() { calls.Add(1) })
time.Sleep(55 * time.Millisecond)
r.Stop()
after := calls.Load()
time.Sleep(40 * time.Millisecond)
fmt.Println("ran several times:", after >= 4, "| ran again after Stop:", calls.Load() != after)
}ran several times: true | ran again after Stop: falseThe ticker is created inside the goroutine and stopped by a defer in the same place, so its lifetime is exactly the loop's. Stop cancels the context and waits for the goroutine to finish, so when it returns nothing is running. The final line checks both halves: it ran several times, and the count did not change after Stop.
Choosing between Ticker, Timer and AfterFunc
The three timer types fit three jobs. A Ticker repeats: use it when something must happen at a regular interval for a while. A Timer fires once: use it for a timeout or a delayed action, and Reset it to reuse it. time.AfterFunc(d, f) runs f in its own goroutine after d and returns a timer you can stop, which suits fire-and-forget callbacks that need no loop of their own. A repeating job can also be written as a timer that re-arms itself after each run, which gives the interval between the end of one run and the start of the next instead of a fixed schedule; that is often what you want when a run may take a long time, since it cannot overlap itself (periodic jobs compares the two).
Mistakes
Ranging over ticker.C without another exit. Forgetting that the first tick is delayed. Assuming ticks queue. Creating a ticker per iteration instead of once; use one ticker and Reset. Calling Reset or NewTicker with a zero or negative duration, which panics. Doing slow work inside the tick handler so the ticker drops ticks; hand the work to another goroutine, or accept the pacing. Stopping a ticker from one goroutine while another goroutine is blocked reading it, which leaves the reader hanging unless it has another exit.
The type is documented in time.Ticker, including the Stop and Reset semantics, and time.NewTicker is described in the time package.


