The background timer that outlives your function
A background worker checks for new jobs every second. The developer writes a loop, adds a time.After call inside a select, and deploys. The service runs for three days. Memory usage climbs from 50 megabytes to 400 megabytes. The garbage collector runs on schedule. CPU stays flat. The leak is invisible to standard profiling tools until you dump the heap and see thousands of dormant timer structures waiting for a deadline that will never be checked.
The code looks harmless. It uses a standard library function that feels like a simple sleep. The problem is that time.After hands you a channel but keeps the timer running in the background. When the loop exits early or the channel is never read, the runtime keeps the timer alive until the duration expires. Those orphaned timers accumulate. Memory grows. The program eventually slows down or crashes.
How time.After works under the hood
time.After is a convenience wrapper. You pass it a duration, and it returns a <-chan time.Time. When the duration passes, the channel sends a single value. You can read from it in a select statement to implement timeouts or polling intervals.
The function does not return the timer itself. It creates a *time.Timer internally, starts it, and returns only the channel. The runtime stores that timer in a global min-heap. The heap tracks deadlines and wakes up a scheduler goroutine when the earliest timer is ready to fire. When the deadline arrives, the runtime sends on the channel and removes the timer from the heap.
Think of it like ordering food at a restaurant. time.After is like giving your order to the host and walking away. The kitchen starts cooking immediately. If you leave before the food is ready, the kitchen still finishes the meal and puts it on a warming tray until someone claims it or it goes cold. time.NewTimer is like giving the kitchen your table number. If you leave early, you can call back and tell them to cancel the order. The kitchen stops cooking and throws away the ingredients.
The runtime cannot garbage collect the internal timer because it holds a reference to it in the heap. The channel you receive is just a door to the timer's internal buffer. Closing the door does not stop the kitchen. The timer lives until it fires or until you explicitly stop it.
The leak in action
Here is the pattern that causes the leak. A function checks a condition with a timeout, but returns early on success.
func checkWithTimeout(ctx context.Context) error {
// Create a timer that fires in 5 seconds
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
// Timeout reached, handle it
return fmt.Errorf("operation timed out")
}
}
The function looks correct. It respects context cancellation. It waits for a timeout. The leak appears when ctx.Done() fires before the five seconds pass. The function returns immediately. The time.After timer is still running in the background. It will fire in a few seconds, send a value on a channel that no one is reading, and then clean itself up. If this function runs thousands of times a second, you create thousands of background timers. Most of them fire and clean up quickly. Some of them stack up if the runtime is busy or if the duration is long. The heap grows. Memory usage climbs.
The runtime timer implementation uses a single heap per goroutine scheduler. Inserting and removing timers is fast. The problem is not CPU. The problem is allocation. Each timer allocates a heap node, a channel buffer, and a deadline entry. When you create them faster than they fire, you leak memory.
Fixing it with time.NewTimer
The fix replaces the convenience function with the explicit type. time.NewTimer returns a *time.Timer struct. The struct exposes a .C channel for reading the deadline and a .Stop() method for cancellation.
func checkWithTimeout(ctx context.Context) error {
// Create a cancellable timer
timer := time.NewTimer(5 * time.Second)
defer timer.Stop() // Prevent leak if we return early
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return fmt.Errorf("operation timed out")
}
}
The defer timer.Stop() call guarantees cleanup. If ctx.Done() fires first, the function returns. The deferred Stop() runs. The runtime removes the timer from the heap. No background goroutine waits. No heap node lingers. Memory stays flat.
The Stop() method returns a boolean. It returns true if the timer was stopped before it fired. It returns false if the timer already fired or was already stopped. You rarely need to check the return value unless you are building a precise state machine. The method is safe to call multiple times. It is also safe to call after the timer fires. The runtime handles the race conditions internally.
Convention aside: always name the context parameter ctx and place it first. Functions that accept a context should respect cancellation immediately. Pairing context.Context with explicit timer cleanup is the standard Go pattern for timeouts. The community expects it. Reviewers will flag time.After in long-running loops or early-return functions.
Common pitfalls and runtime behavior
Developers often misunderstand how Stop() interacts with the channel. If you stop a timer after it has already fired, the channel still contains the value. Reading from .C will succeed. The runtime does not drain the channel when you call Stop(). If you need to guarantee that the timeout did not happen, check the boolean return value or use a separate flag.
Another common mistake is creating a timer inside a tight loop without stopping it.
func tightLoop() {
for {
// Creates a new timer every iteration
<-time.After(100 * time.Millisecond)
}
}
This loop runs continuously. Each iteration creates a timer. The previous timer fires and cleans up before the next one starts. This specific pattern does not leak because the timers fire in order. The leak happens when the loop exits early, or when the duration is longer than the loop interval, or when the channel is never read. If you rewrite this loop to break on a condition, the dangling timers accumulate.
The compiler will not catch these mistakes. Timer leaks are runtime behavior. You will not see undefined: timer or cannot use time.After as int. The code compiles cleanly. The leak shows up in memory profiles or production dashboards. If you accidentally call Stop() on a nil pointer, the runtime panics with runtime error: nil pointer dereference. Always initialize timers before using them. If you forget to import the time package, the compiler rejects the file with undefined: time. If you import it and never use it, you get imported and not used. These are basic checks. The timer leak requires runtime awareness.
Convention aside: gofmt does not care about timer patterns. It only formats syntax. You must enforce cleanup patterns through code review or linters. The errcheck linter will not catch missing Stop() calls because Stop() returns a boolean, not an error. Use staticcheck or custom rules to flag time.After in functions with early returns.
When to reach for which timer
Go provides four timer utilities. Each solves a different problem. Pick the right one based on your lifecycle needs.
Use time.After when you need a one-off timeout in a function that cannot return early and where the duration is short enough that orphaned timers are harmless. Use time.NewTimer when you need explicit control over the timer lifecycle, especially in functions with multiple return paths or long-running loops. Use time.NewTicker when you need repeated intervals that automatically reset after each tick, and remember to call Stop() when the ticker is no longer needed. Use time.AfterFunc when you want to execute a callback at a specific deadline instead of sending on a channel, and always store the returned *time.Timer so you can cancel it if the callback is no longer relevant.
The runtime scheduler expects timers to be cleaned up. Leaving them behind wastes memory and adds noise to the scheduler heap. Explicit cleanup is not optional. It is part of the function contract.