The waiting game
You are building a background worker that needs to check a database every thirty seconds. Or you are writing a CLI tool that should give up after five seconds if a remote server does not respond. You reach for time.Sleep, but it blocks the entire goroutine. You need a way to wait without freezing, or to repeat an action without writing a manual loop. The standard library offers time.After and time.Tick. They look simple. They hide a subtle resource leak that trips up almost every new Go developer.
How timers actually work
Timers in Go are built on channels. A channel is a typed pipe that connects goroutines. time.After hands you a channel that delivers exactly one value after a set duration. time.Tick hands you a channel that delivers values repeatedly. The difference is not just frequency. It is about who owns the cleanup.
Think of time.After like a single-use alarm clock. You set it, it rings once, and it goes silent. Think of time.Tick like a metronome that starts ticking and never stops. There is no off switch. If you walk away from the room, it keeps ticking forever. time.NewTicker is the same metronome, but it comes with a power cord you can unplug.
Go schedules these timers in the runtime. Every timer spawns a lightweight goroutine that waits for the deadline, sends the current time down the channel, and then exits. That goroutine is cheap, but it is not free. If you create a ticker and never stop it, the runtime keeps scheduling ticks into a channel that nobody is reading. The goroutine blocks forever. The memory stays allocated. That is a goroutine leak.
Timers are one-shot. Once the channel receives a value, the timer is done.
The single-shot alarm
Here is the simplest way to wait for a single deadline without blocking your goroutine:
package main
import (
"fmt"
"time"
)
func main() {
// Create a channel that will receive exactly one time value
deadline := time.After(2 * time.Second)
// Block until the channel sends, or another condition triggers
select {
case t := <-deadline:
// The timer fired. t holds the exact moment it triggered.
fmt.Println("Deadline reached at", t)
}
}
The select statement waits on multiple channels at once. It picks one that is ready. If the timer fires first, the program prints the message and exits. The goroutine behind time.After sends the value and terminates cleanly.
When you call time.After(2 * time.Second), the Go runtime allocates a timer structure and registers it with the scheduler. A dedicated goroutine is attached to that timer. Your main goroutine blocks on the <-deadline receive. Two seconds later, the scheduler wakes the timer goroutine. It sends the current time into the channel. The main goroutine unblocks, reads the value, and continues. The timer goroutine finishes its work and is garbage collected.
Timers are one-shot. Once the channel receives a value, the timer is done.
The metronome that never stops
time.Tick works differently. It creates a global ticker that lives for the entire program lifetime. It does not give you a way to stop it. If you call time.Tick(1 * time.Second) inside a function that runs ten times, you create ten independent tickers. Each one spawns its own goroutine. Each one keeps sending values into a channel that you immediately discard. The runtime accumulates blocked goroutines until the process runs out of memory or file descriptors.
The Go documentation explicitly warns against time.Tick. It exists for historical reasons and for trivial scripts that run once and exit. Production code needs control over the lifecycle.
Developers sometimes try to work around the leak by discarding the channel with an underscore. Writing _, _ = time.Tick(1 * time.Second) does not stop the goroutine. It just tells the compiler you intentionally ignored the return value. The ticker still runs. The goroutine still blocks. The leak still happens.
Tickers without an off switch are a memory leak waiting to happen.
Production-grade intervals
Real applications need to stop timers when a request finishes, a context cancels, or a server shuts down. time.NewTicker returns a *Ticker struct with a Stop() method. You pair it with defer to guarantee cleanup, and you wire it into a select block so it can race against other signals.
package main
import (
"context"
"fmt"
"time"
)
// runWorker processes tasks until the context is cancelled.
func runWorker(ctx context.Context) {
// Create a ticker that fires every 5 seconds.
ticker := time.NewTicker(5 * time.Second)
// Ensure the ticker stops when this function returns.
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// Context was cancelled. Exit the loop immediately.
fmt.Println("Worker shutting down")
return
case t := <-ticker.C:
// Ticker fired. Perform the periodic work.
fmt.Println("Checking status at", t)
}
}
}
The defer ticker.Stop() call schedules cleanup for when runWorker returns. It does not matter whether the function exits normally or panics. The ticker stops. The underlying goroutine unblocks and exits. The select statement checks both the context and the ticker. If the context cancels first, the loop breaks. If the ticker fires first, the work runs. This pattern prevents leaks and respects cancellation signals.
Context always travels as the first parameter. Pass it down, check it early, and stop timers when it closes.
What happens when you get it wrong
Developers often try to stop a ticker by breaking out of a loop and hoping the garbage collector cleans it up. It does not work that way. The ticker holds a reference to its internal channel. The channel holds a reference to the scheduler. The scheduler holds a reference to the goroutine. Nothing gets collected until Stop() is called.
Another common mistake is ignoring the ticker channel after a certain number of ticks. If you read five values and then stop listening, the goroutine blocks on the sixth send. The channel buffer is zero by default. The sender waits for a receiver that never arrives. The goroutine hangs forever.
You can avoid this by using a buffered channel or by wrapping the ticker in a wrapper that drops excess ticks, but the simplest fix is to stop the ticker when you are done. If you need to limit iterations, count them and call Stop() manually.
Sometimes developers try to close the ticker channel manually to signal completion. The compiler rejects this with cannot close receive-only channel if you try to close ticker.C. If you somehow get a reference to the underlying channel and close it, the runtime panics with panic: send on closed channel the next time the scheduler tries to deliver a tick. Never close channels you do not own.
The compiler will not catch a goroutine leak. The runtime will not panic. The process will just slowly consume memory until it crashes under load.
Always stop what you start. The runtime does not clean up abandoned timers.
Testing time-dependent code
Writing tests for code that sleeps or waits on timers is slow. A test that waits five seconds blocks the entire test suite. The standard library provides time.AfterFunc for one-off callbacks, but it still uses real time. The idiomatic solution is to abstract the time source behind an interface.
package main
import "time"
// Clock abstracts time operations for testability.
type Clock interface {
After(d time.Duration) <-chan time.Time
NewTicker(d time.Duration) *time.Ticker
}
// RealClock delegates to the standard library.
type RealClock struct{}
func (RealClock) After(d time.Duration) <-chan time.Time {
return time.After(d)
}
func (RealClock) NewTicker(d time.Duration) *time.Ticker {
return time.NewTicker(d)
}
In production, you pass RealClock{}. In tests, you implement a fake clock that returns immediately or advances time manually. This keeps tests fast and deterministic. The interface is small, but it removes the dependency on wall-clock time.
Accept interfaces, return structs. Let callers decide how time flows.
When to reach for what
Use time.After when you need a single deadline or timeout and you want to keep the code concise. Use time.NewTicker when you need repeated intervals and you must guarantee cleanup with Stop(). Use time.Sleep when you are inside a single goroutine that does not need to do anything else while waiting. Avoid time.Tick in any code that runs longer than a few seconds or executes more than once.