Right, let’s talk about two of Go’s most useful and, frankly, most misused constructs: time.Ticker and time.Timer. These are your tools for telling your code to “do this thing later” or “do this thing repeatedly.” They’re deceptively simple, and that’s where everyone gets tripped up. I’ve seen more production bugs related to these than I care to admit, so pay attention.

The core thing to understand is that both Ticker and Timer are channels. That’s it. Their entire API is a single channel, C, of type <-chan time.Time. Their job is to send a value on that channel at the appointed time. The difference is in their cadence: a Timer fires once; a Ticker fires repeatedly until you stop it.

The One-Shot Wonder: time.Timer

Think of a Timer as a single-use kitchen timer. You set it, it dings once, and then it’s done. You create one with time.NewTimer(d Duration), which gives you a *Timer. The channel C on that pointer will receive a value after the duration d has elapsed.

// You need a function to run after a 2-second delay.
fmt.Println("Setting the timer...")
timer := time.NewTimer(2 * time.Second)
<-timer.C // This blocks until the timer fires.
fmt.Println("Ding! Timer fired.")

But here’s the first “gotcha”: what if you change your mind? The timer is still ticking down in the background, wasting resources. This is why the Stop() method exists. It returns a bool telling you if it successfully stopped the timer (true) or if the timer had already fired (false). Always check it if you care about cleaning up.

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

// Oh, never mind, we don't need to wait that long.
go func() {
    time.Sleep(1 * time.Second)
    if stopped := timer.Stop(); stopped {
        fmt.Println("Timer stopped successfully. Disaster averted.")
    }
}()

// This would now block forever if the timer was stopped, causing a goroutine leak.
// But we're smart and we only read from it if we know it fired.
select {
case <-timer.C:
    fmt.Println("This would have been bad if we stopped it.")
default:
    // This is a neat trick to drain the channel if it's ready.
}

And then there’s time.AfterFunc, which is the cooler, more direct sibling. You give it a duration and a function, and it handles the goroutine for you. It’s perfect for fire-and-forget delays.

// This prints "Hello from the future!" after 1 second, in its own goroutine.
time.AfterFunc(1*time.Second, func() {
    fmt.Println("Hello from the future!")
})
// Your main code continues immediately without blocking.

The Metronome: time.Ticker

A Ticker is your interval timer. You create it with time.NewTicker(d Duration), and it will dutifully send a time.Time on its channel C every d period until you explicitly tell it to shut up with Stop().

// A heartbeat that ticks every second.
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() // Crucial! This releases the underlying timer resource.

for {
    <-ticker.C
    fmt.Println("Tick")
}

This seems straightforward, but here lies the most common and insidious pitfall: ticker drift. The above code is a terrible example for anything that requires precise timing. Why? Because the operation fmt.Println("Tick") takes time. The ticker fires every 1 second + the time it takes to execute the loop body. For a quick operation, it’s negligible. For a slow one, your “every second” task slowly becomes an “every 1.2 seconds” task.

The correct way to think about a ticker is not “do this every X,” but “wake me up every X.” You should design your task to be completed within an interval. If you need truly precise, fixed-interval scheduling, you need a different approach, often involving calculating the next wake time yourself.

Another critical point: you must stop your ticker. If you let a ticker go out of scope without stopping it, it will continue to tick and hold that timer resource forever, leading to a goroutine leak. It’s one of the classic memory leaks in Go applications. Always, always defer ticker.Stop() right after you create it.

The Subtle Art of the time.After

You’ll often see time.After(d Duration) used in select statements for timeouts. It’s incredibly convenient.

select {
case msg := <-someChannel:
    fmt.Println("Got message:", msg)
case <-time.After(3 * time.Second):
    fmt.Println("Timed out waiting for message.")
}

But you must understand what time.After is: it’s a Timer in a convenient wrapper. And here’s the kicker: the timer isn’t garbage collected until it fires. So if someChannel receives a message after 1 second, the time.After timer is still sitting there, counting down for another 2 seconds before it can be cleaned up. In a tight loop that happens thousands of times a second, you can end up with thousands of active timers piling up, crushing your application’s memory. For these scenarios, you must use a single, reusable Timer with Reset(). It’s more code, but it’s the responsible choice.

So there you have it. They’re just channels, but the responsibility for their lifecycle is entirely on you. Use them wisely, stop them diligently, and for heaven’s sake, don’t let them drift.