Used a second channel as Volker suggested. This is what I ended up running with:

package main

import (
    "log"
    "time"
)

// Run the function every tick
// Return false from the func to stop the ticker
func Every(duration time.Duration, work func(time.Time) bool) chan bool {
    ticker := time.NewTicker(duration)
    stop := make(chan bool, 1)

    go func() {
        defer log.Println("ticker stopped")
        for {
            select {
            case time := <-ticker.C:
                if !work(time) {
                    stop <- true
                }
            case <-stop:
                return
            }
        }
    }()

    return stop
}

func main() {
    stop := Every(1*time.Second, func(time.Time) bool {
        log.Println("tick")
        return true
    })

    time.Sleep(3 * time.Second)
    log.Println("stopping ticker")
    stop <- true
    time.Sleep(3 * time.Second)
}
Answer from whatupdave on Stack Overflow
🌐
Go by Example
gobyexample.com › tickers
Go by Example: Tickers
Timers are for when you want to do something once in the future - tickers are for when you want to do something repeatedly at regular intervals. Here’s an example of a ticker that ticks periodically until we stop it · Tickers use a similar mechanism to timers: a channel that is sent values.
🌐
Go Packages
pkg.go.dev › time
time package - time - Go Packages
NewTicker returns a new Ticker containing a channel that will send the current time on the channel after each tick. The period of the ticks is specified by the duration argument. The ticker will adjust the time interval or drop ticks to make up for slow receivers.
Discussions

go - Ticker Stop behaviour in Golang - Stack Overflow
2013/07/22 14:26:53 tick 2013/07/22 14:26:54 tick 2013/07/22 14:26:55 tick 2013/07/22 14:26:55 stopping ticker · So that goroutine never exits. Is there a better way to handle this case? Should I care that the goroutine never exited? ... The golang docs should really expand on this, but their ... More on stackoverflow.com
🌐 stackoverflow.com
Tickers on gobyexample
I think in this case it wouldn’t matter but in general you should take care of any goroutines before leaving the routine-creating routine. Let’s say main was a function called many times. It would leak goroutines without the done chan. Explanation if this isn’t already clear to you More on reddit.com
🌐 r/golang
9
7
October 23, 2023
go - Golang time.Ticker triggers twice after blocking a while - Stack Overflow
The ticker and main goroutine race to the 6s mark because the sleep is a multiple of the ticker period. In your trace, the main goroutine wins the race. On my machine, the ticker wins the race and I get the timing that you expect. More on stackoverflow.com
🌐 stackoverflow.com
go - Correct way to test code that uses time.Ticker? - Stack Overflow
I'd like your advice on the correct way to test code that uses time.Ticker For instance, let's say I have a countdown timer like below (just an example I thought up for the purposes of this questi... More on stackoverflow.com
🌐 stackoverflow.com
🌐
Reddit
reddit.com › r/golang › tick improved in go 1.23
r/golang on Reddit: Tick improved in Go 1.23
November 13, 2024 -

Somehow I missed this in the release notes, but I was soo happy to read this today. All the little things add up to make life better!

Before Go 1.23, this documentation warned that the underlying Ticker would never be recovered by the garbage collector, and that if efficiency was a concern, code should use NewTicker instead and call Ticker.Stop when the ticker is no longer needed. As of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven't been stopped. The Stop method is no longer necessary to help the garbage collector. There is no longer any reason to prefer NewTicker when Tick will do.

Ticker with instant start Dec 2, 2016
r/golang
9y ago
The Go Optimization Guide Mar 31, 2025
r/golang
last yr.
Go 1.26 Feb 10, 2026
r/golang
5mo ago
Interactive release notes for Go 1.23 Jul 20, 2024
r/golang
last yr.
More results at reddit.com
🌐
Leapcell
leapcell.io › blog › understanding-golang-ticker-a-guide-to-timed-operations
Understanding Golang Ticker: A Guide to Timed Operations | Leapcell
July 25, 2025 - Ticker differs from Timer by continuously emitting events, whereas Timer triggers only once. Golang’s time.Ticker is a useful tool for executing periodic tasks at fixed intervals.
🌐
Boot.dev
boot.dev › blog › golang › range over ticker in go with immediate first tick
Range Over Ticker In Go With Immediate First Tick | Boot.dev
April 30, 2020 - package main import ( "fmt" "time" ) func main() { ticker := time.NewTicker(time.Second) go func() { for range ticker.C { fmt.Println("Tick") } }() time.Sleep(time.Second * 4) ticker.Stop() fmt.Println("Ticker stopped") }
Find elsewhere
🌐
GitHub
github.com › golang › go › blob › master › src › time › tick.go
go/src/time/tick.go at master · golang/go
// Reset stops a ticker and resets its period to the specified duration. // The next tick will arrive after the new period elapses. The duration d · // must be greater than zero; if not, Reset will panic.
Author   golang
🌐
DEV Community
dev.to › ankitmalikg › go-ticker-vs-timer-4glb
Go: Ticker vs Timer - DEV Community
March 11, 2023 - In this example, we create a new ticker using the time.NewTicker function and pass it an interval of 500 milliseconds. We then create a goroutine that loops over the ticker's C channel, printing the current time each time the ticker fires.
🌐
Google Groups
groups.google.com › g › golang-nuts › c › IrVXTCuo8CU
time.Ticker's behavior
May 25, 2020 - If you add a bit more info to your app it becomes clearer: https://play.golang.org/p/gHZXgyIuYw7 · Here is what is happening: 100ms - The ticker fires and sends a tick timestamped 100ms. 100ms - Your goroutine wakes, receives the tick timestamped 100ms. 100+ms - Your goroutine goes to sleep at line 30 (for one second).
🌐
Go
go.dev › src › time › tick.go
- The Go Programming Language
The ticker will adjust 24 // the time interval or drop ticks to make up for slow receivers. 25 // The duration d must be greater than zero; if not, NewTicker will 26 // panic.
🌐
GitHub
github.com › mmadfox › go-ticker
GitHub - mmadfox/go-ticker: Abstraction over the standard golang ticker with hook support · GitHub
package main import ( "context" "fmt" "github.com/mmadfox/go-ticker" "time" ) const oneMinute = time.Minute func main() { tick, err := ticker.Every(oneMinute, ticker.WaitNextLoop()) if err != nil { panic(err) } ctx := context.Background() tick.Handle(new(myHandler)) tick.Start(ctx) <-time.After(15 * time.Minute) tick.Stop() } type myHandler struct { } func (h *myHandler) Handle(_ context.Context, tick, timePoint time.Time) { fmt.Printf("handle time:%s, tick:%s\n", timePoint, tick) } func (h *myHandler) BeforeStart(_ context.Context) { fmt.Println("beforeStart") } func (h *myHandler) Tick(_ context.Context, tick time.Time, next bool) { fmt.Println("tick", tick, next) } func (h *myHandler) AfterStop(_ context.Context) { fmt.Println("afterStop") }
Author   mmadfox
Top answer
1 of 2
5

The ticker creates a buffered channel with a capacity of one. The ticker drops time values when the channel is full.

Here's the timeline for the trace in the question. Events listed for the same time happen a tiny bit after each other in the order listed.

1s - ticker sends value
1s - main goroutine receives first value
1s - main goroutine starts sleep for 5s
2s - ticker sends value (channel has capacity 1).
3s - ticker fails to send value because channel is full
4s - ticker fails to send value because channel is full
5s - ticker fails to send value because channel is full
6s - main goroutine exits sleep and receives buffered value
6s - ticker sends value
6s - main goroutine receives buffered value

The ticker and main goroutine race to the 6s mark because the sleep is a multiple of the ticker period. In your trace, the main goroutine wins the race. On my machine, the ticker wins the race and I get the timing that you expect. Here's what it looks like on my machine.

...
5s - ticker fails to send value because channel is full
6s - ticker fails to send value because channel is full
6s - main goroutine receives buffered value
7s - ticker sends value
7s - main goroutine receives value

The ticker wins the race to the 6s mark on the playground. Run it here. The main goroutine wins the race when the sleep time is nudged down by a 1µs. Run it here.

This is easy to see when the sleep duration is not a multiple of the ticker period. Here's what happens when the sleep duration is 2.5 seconds:

1s - ticker sends value
1s - main goroutine receives first value
1s - main goroutine starts sleep for 2.5s
2s - ticker sends value
3s - ticker fails to send value because channel is full
3.5s - main goroutine exits sleep and receives buffered value
4s - ticker sends value
4s - main goroutine receives value

Run the last example on the playground.

2 of 2
2

In time.Ticker, there is a chan Time. In the NewTicker(d Duration) *Ticker function, It creates tickers Time channel as a buffered channel with capacity 1. In the function, it is mentioned as below.

// Give the channel a 1-element time buffer.
// If the client falls behind while reading, we drop ticks
// on the floor until the client catches up.

In your case, time ticks in every second and write to the channel. While sleeping, the ticker writes one time to the channel, and after the loop continues it is immediately received by select case and print. For the other 4 seconds, the channel is blocking and ticks are dropped. Immediately loop continues it is unblocked and accept more ticks to the channel.

Print the time comes through the channel and you can easily understand the behavior. Updated code as below.

func main() {
    ticker := time.NewTicker(1 * time.Second)

    i := 0
    for {
        select {
        case t := <-ticker.C:
            log.Println(i, t)
            if i == 0 {
                time.Sleep(5 * time.Second) // simulate the blocking
            }
            i++
        }
    }
}

Output:

2022/02/20 08:47:58 0 2022-02-20 08:47:58.773375 +0530 +0530 m=+1.004241004
2022/02/20 08:48:03 1 2022-02-20 08:47:59.772787 +0530 +0530 m=+2.003666993
2022/02/20 08:48:03 2 2022-02-20 08:48:03.774272 +0530 +0530 m=+6.005207433 // printing time is same, but channel's received time has 4 sec difference.
2022/02/20 08:48:04 3 2022-02-20 08:48:04.774203 +0530 +0530 m=+7.005151715

Run in Playground.

🌐
GitHub
gist.github.com › ngauthier › d6e6f80ce977bedca601
Golang timeout and tick loop · GitHub
Golang timeout and tick loop. GitHub Gist: instantly share code, notes, and snippets.
Top answer
1 of 2
4

Since this is a pretty simple function, I assume you are just using this as an example of how to mock non-trivial stuff. If you actually wanted to test this code, rather than mocking up ticker, why not just use really small intervals.

IMHO the 2nd option is the better of the two, making a user call:

foo(dur, NewTicker(interval)... 

doesn't seem like much of a burden.

Also having the callback is serious code smell in Go:

func Countdown(ticker Ticker, duration time.Duration) chan time.Duration {
    remainingCh := make(chan time.Duration, 1)
    go func(ticker Ticker, dur time.Duration, remainingCh chan time.Duration) {
        for remaining := duration; remaining >= 0; remaining -= ticker.Duration() {
            remainingCh <- remaining
            ticker.Tick()
        }
        ticker.Stop()
        close(remainingCh)
    }(ticker, duration, remainingCh)
    return remainingCh
}

You could then use this code like:

func main() {
    for d := range Countdown(NewTicker(time.Second), time.Minute) {
        log.Printf("%v to go", d)
    }
}

Here it is on the playground: http://play.golang.org/p/US0psGOvvt

2 of 2
0

This doesn't answer the how to inject the mock part, but it seems like you are trying too hard. if the example is representative of what you actually are testing, then just use small numbers.

http://play.golang.org/p/b_1kqyIu-u

Countdown(5, 1, func(d time.Duration) {
        log.Printf("%v to go", d)
    })

Now, if you are testing code that calls Countdown (rather than testing Countdown), then I'd probably just create a flag you can set for your module that scales the numbers to be as fast possible with the same invocation count.

http://play.golang.org/p/KqCGnaR3vc

if testMode {
    duration = duration/interval
    interval = 1
}
🌐
GeeksforGeeks
geeksforgeeks.org › go language › time-ticker-stop-function-in-golang-with-examples
time.Ticker.Stop() Function in Golang With Examples - GeeksforGeeks
April 28, 2025 - Return Value: It returns the output of the stated operation before calling the stop() method because after calling it ticker is turned off. ... // Golang program to illustrate the usage of // time.Ticker.Stop() function // Including main package package main // Importing fmt and time import "fmt" import "time" // Calling main func main() { // Calling NewTicker method Ticker := time.NewTicker(2 * time.Second) // Creating channel using make // keyword mychannel := make(chan bool) // Go function go func() { // Using for loop for { // Select statement select { // Case statement case <-mychannel: r