How to Implement Rate Limiting with time.Ticker in Go

Implement rate limiting in Go by creating a time.Ticker and looping over its channel to enforce fixed time intervals between actions.

When loops run too fast

You build a service that polls an external API for inventory updates. The provider allows fifty requests per second. Your for loop executes as fast as the CPU allows, hammers the endpoint, and triggers an automated IP ban within three minutes. You need a steady, predictable pace that respects the limit without guessing delays or scattering time.Sleep calls throughout your code.

How time.Ticker actually works

A time.Ticker is a metronome for your goroutine. It creates a read-only channel that delivers a time.Time value at fixed intervals. When you block on that channel, your program pauses until the next tick arrives. The ticker runs independently in the background, maintaining its own internal clock. You do not calculate delays yourself. You simply wait for the signal.

The channel is unbuffered. The ticker goroutine blocks until your code reads the value. This design guarantees that the interval is measured from the moment the previous tick was delivered, not from when your code starts processing. If your work takes longer than the interval, the ticker skips beats to catch up. It never queues up delayed signals. The pacing is relative to the last successful delivery, not to wall-clock time.

Tickers pace execution. They do not guarantee throughput.

Minimal example

Here is the simplest ticker loop: create the ticker, defer its cleanup, and range over the channel.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Create a ticker that fires every 500 milliseconds
	ticker := time.NewTicker(500 * time.Millisecond)
	// Stop releases the background goroutine and prevents leaks
	defer ticker.Stop()

	// Range blocks until the next tick arrives
	for range ticker.C {
		// Print the current time to verify the interval
		fmt.Println("Tick:", time.Now().Format(time.TimeOnly))
	}
}

What happens under the hood

When the program starts, time.NewTicker allocates a time.Ticker struct and launches a hidden goroutine. That goroutine enters a loop, waits for the specified duration, and sends the current time into ticker.C. The range statement in main pulls values from that channel. Each pull unblocks the loop, runs the body, and immediately blocks again on the next iteration.

The hidden goroutine keeps running until ticker.Stop() is called. If you exit main without stopping it, the garbage collector eventually cleans it up, but the Go runtime will warn you about a goroutine leak during testing. The defer statement guarantees cleanup even if a panic occurs. This is a hard convention in Go: any resource that spawns a background goroutine must be stopped explicitly.

The interval measurement is the key detail. The ticker calculates the next send time based on when the previous send completed. If your loop body takes two seconds to run, but the interval is set to one second, the ticker waits zero seconds before sending the next value. It does not queue the missed tick. Your code simply runs as fast as it can after the delay. This behavior is intentional. It prevents channel backpressure from building up when your workload slows down.

Always stop the ticker. Background goroutines are cheap, but leaked ones are expensive.

Realistic worker with context

Real services rarely run forever. They need cancellation, error handling, and structured logging. Here is how a ticker integrates into a long-running worker that respects context deadlines.

package main

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

// RunWorker polls an external service at a fixed pace
func RunWorker(ctx context.Context, interval time.Duration) error {
	ticker := time.NewTicker(interval)
	// Stop releases the background goroutine when the function returns
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			// Exit immediately when the parent context cancels
			return ctx.Err()
		case <-ticker.C:
			// Proceed only when the rate limit window opens
			if err := doWork(ctx); err != nil {
				return fmt.Errorf("worker failed: %w", err)
			}
		}
	}
}

The select statement replaces the range loop. It allows the ticker to compete with context cancellation. When ctx.Done() fires, the worker exits cleanly without waiting for the next tick. The error wrapping follows Go convention: verbose but explicit. The compiler rejects the program with cannot use ctx.Err() (value of type error) as string value in return if you try to return it directly without wrapping or assigning it to an error variable. Context always travels as the first parameter, conventionally named ctx. Functions that accept it must check for cancellation before doing expensive work.

Here is the setup that drives the worker:

func main() {
	// Create a context with a 5-second deadline
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// Start the worker and handle its exit error
	if err := RunWorker(ctx, 1*time.Second); err != nil {
		fmt.Println("Worker stopped:", err)
	}
}

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

Pitfalls and runtime behavior

Tickers behave differently than timers. A time.Timer fires once and stops. A time.Ticker fires repeatedly until you stop it. Confusing the two leads to memory leaks or unexpected behavior. If you only need a single delay, use time.After or time.NewTimer.

Processing time longer than the interval causes drift. The ticker does not compensate for slow work. It simply skips the missed beats. If you need strict pacing regardless of processing time, you must track wall-clock time yourself or use a token bucket algorithm. Tickers are designed for steady-state pacing, not deadline enforcement.

Sending to a closed ticker channel panics. You never send to ticker.C. The ticker goroutine owns the send side. If you accidentally assign ticker.C to a variable and try to write to it, the compiler rejects the program with cannot send on receive-only channel ticker.C. The channel type <-chan time.Time enforces this at compile time.

Forgetting Stop() is the most common mistake. The ticker goroutine holds a reference to the timer and the channel. Without Stop(), the runtime keeps it alive until the program exits. In long-running servers, this silently consumes file descriptors and goroutine slots. The defer pattern right after creation eliminates this class of bugs entirely.

Tickers pace loops. They do not replace proper concurrency control.

When to pick a ticker

Use time.Ticker when you need a steady, repeating signal to pace a loop or background job. Use time.Sleep when you need a single, one-off delay between two specific operations. Use golang.org/x/time/rate when you need a token bucket that allows bursts while maintaining an average rate. Use a buffered channel as a semaphore when you need to limit concurrent goroutines rather than sequential execution pace.

Pick the tool that matches the rhythm of your workload.

Where to go next