How to Create Timers and Tickers in Go

Create one-off delays with time.NewTimer and repeating intervals with time.NewTicker in Go.

The alarm clock and the metronome

You need a background job to run once after a delay, or a process that fires every few seconds to sync data. In Go, you don't write a while loop with time.Sleep. You use the standard library's time.Timer and time.Ticker. They look similar on the surface, but they solve different problems and behave differently under the hood.

A timer is a single alarm clock. You set it, it rings once, and you're done. A ticker is a metronome. It keeps clicking at a steady pace until you tell it to stop. Both work through channels. The time package gives you a struct with a C channel. When the time comes, the runtime sends a time.Time value into that channel. Your code reads from C to react.

How the runtime schedules time

Go does not rely on OS signals for these primitives. The runtime maintains a heap-based priority queue of timers. When you create a timer or ticker, the runtime calculates the next firing time and inserts it into the queue. A dedicated system goroutine scans the queue, sleeps until the earliest timer is due, and then sends the current time into the associated channel.

This design means timers and tickers are accurate enough for application logic, but not for hard real-time systems. The scheduler runs on a regular tick interval, and channel sends are subject to goroutine scheduling latency. You get millisecond-level precision in practice, which covers 99 percent of backend needs.

The C channel is unbuffered by default. If nobody is ready to receive, the send blocks until a receiver appears. This is intentional. It forces you to handle the event instead of silently dropping it.

Goroutines are cheap. Channels are not magic.

A minimal timer

Here's the simplest timer: create one, wait on its channel, and let the program exit.

package main

import (
	"fmt"
	"time"
)

func main() {
	// NewTimer allocates a timer and starts it immediately.
	// The duration is relative to the current time.
	timer := time.NewTimer(2 * time.Second)

	// Block until the runtime sends a time.Time value.
	// The channel is unbuffered, so this read waits for the send.
	<-timer.C

	// The timer has fired. The value is discarded intentionally.
	fmt.Println("Timer fired")
}

The program blocks on <-timer.C for two seconds. The runtime wakes up, sends the current timestamp, and the read completes. The fmt.Println runs, and main returns. If you forget to read from C, the timer still fires, but the send blocks until the program exits or another goroutine reads it. The compiler won't stop you from ignoring the channel, but the runtime will pause the timer goroutine until someone listens.

If you try to assign the timer directly to a channel variable without reading C, the compiler rejects it with cannot use time.NewTimer(2 * time.Second) as type <-chan time.Time in assignment. You must access the .C field.

A minimal ticker

Here's the simplest ticker: spawn a goroutine to read ticks, let it run for a fixed window, and stop it.

package main

import (
	"fmt"
	"time"
)

func main() {
	// NewTicker creates a ticker that fires immediately,
	// then repeats at the given interval.
	ticker := time.NewTicker(1 * time.Second)

	// Run the ticker in a separate goroutine to avoid blocking main.
	// The range loop automatically closes when Stop() is called.
	go func() {
		for range ticker.C {
			fmt.Println("Tick")
		}
	}()

	// Let it run for five seconds, then stop the internal timer.
	time.Sleep(5 * time.Second)
	ticker.Stop()

	// Give the goroutine time to exit cleanly.
	time.Sleep(100 * time.Millisecond)
}

The ticker fires roughly every second. The range loop reads from ticker.C until the channel closes. ticker.Stop() closes the channel and releases resources. The goroutine exits cleanly. If you skip Stop(), the ticker keeps firing forever, and the goroutine leaks. The runtime will keep allocating send operations until the process terminates.

The compiler complains with assignment to entry in nil map or similar channel errors if you try to send to C manually. The C channel is read-only to your code. Only the runtime writes to it.

Don't fight the type system. Wrap the value or change the design.

Realistic usage: a context-aware background worker

Production code rarely uses raw timers or tickers in isolation. You need shutdown coordination, error handling, and lifecycle management. Here's a background cache cleaner that ticks every thirty seconds but respects a context for graceful termination.

package main

import (
	"context"
	"fmt"
	"time"
)

// RunCacheCleaner starts a background goroutine that flushes stale entries.
// It stops when ctx is cancelled or the process exits.
func RunCacheCleaner(ctx context.Context) {
	// Ticker fires every 30 seconds.
	// The interval is fixed; drift accumulates if work takes time.
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	// Select on both the ticker and context cancellation.
	// This prevents goroutine leaks when the parent cancels.
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Cleaner shutting down")
			return
		case t := <-ticker.C:
			// Process only if context is still valid.
			// Avoids running cleanup during shutdown.
			if ctx.Err() != nil {
				return
			}
			fmt.Printf("Flushing cache at %s\n", t.Format(time.RFC3339))
		}
	}
}

The select statement is the standard pattern for combining time-based events with cancellation. ctx.Done() closes when the parent context cancels. ticker.C closes when Stop() runs. The defer ticker.Stop() ensures cleanup happens when the function returns, regardless of which branch exits.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and runtime traps

Timers and tickers introduce subtle lifecycle bugs if you ignore their channel semantics.

Forgetting to stop a ticker causes a goroutine leak. The runtime keeps scheduling ticks, and your reader goroutine stays alive. Always pair time.NewTicker with defer ticker.Stop() or an explicit Stop() call. The worst goroutine bug is the one that never logs.

Resetting a timer that already fired without draining the channel causes undefined behavior. If a timer fires and nobody reads from C, the send blocks. Calling Reset() on a blocked timer panics or silently drops the reset depending on the Go version. Always drain C before resetting, or use time.AfterFunc for one-off callbacks that don't require channel reads.

Using time.Sleep instead of a timer when you need cancellation is a design flaw. time.Sleep blocks the goroutine until the duration expires. You cannot interrupt it. If you need to wake up early, use a timer with a select on a context or a done channel.

The compiler rejects programs that ignore multiple return values when you explicitly expect them. If you write timer := time.NewTimer(2 * time.Second) and later try to use timer as a channel, you get a type mismatch error. Access .C explicitly. If you accidentally shadow the time package with a local variable, the compiler says undefined: time. Keep package imports clean and avoid naming collisions.

Convention aside: the context.Context parameter always goes first, conventionally named ctx. Functions that accept a context should check ctx.Err() before starting expensive work, and they should respect cancellation deadlines. This pattern keeps your time-based code composable with the rest of the Go ecosystem.

When to reach for what

Use a time.Timer when you need a single delayed action and want to control the channel lifecycle explicitly. Use a time.Ticker when you need a steady stream of events at fixed intervals and plan to stop it later. Use time.After(d) when you want a quick one-off delay and don't care about stopping it: it returns a channel that fires once and cleans itself up. Use time.AfterFunc(d, f) when you want to run a callback directly without managing a channel or a separate goroutine. Use context.AfterFunc when you need the callback to respect context cancellation and automatic cleanup. Use plain sequential code with time.Sleep when you are writing a simple script or test and cancellation is irrelevant.

Where to go next