Skip to content
gohacks.dev
Concurrency

Debounce and Throttle Functions in Go Built With Timers

Build debounce and throttle helpers in Go with time.AfterFunc: collapse a burst into one trailing call, or allow one call per interval, with tests.

9 min read
Summarize in AI
Flat illustration of an editor window full of code.

Some events come in clumps. A file watcher reports six writes for one save. A search box fires on every keystroke. A config change arrives as a flurry of notifications. Handling each event individually is wasteful at best and wrong at worst: you reload the config six times, send ten requests for a half-typed query. The two standard remedies are debouncing and throttling, familiar from front-end code and just as useful in Go servers, watchers and clients. They are different tools for different shapes of burst, and each is short enough to write yourself.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds both, with the edge choices that decide their behavior, and tests them. It builds on tickers and timers, and relates to rate limiting. Everything ran on Go 1.25.6.

The difference

Debounce waits for quiet. Each call resets a timer; the function runs once, d after the last call. A burst of ten calls spaced closer than d produces one execution, at the end. Use it when you only care about the final state: reload after a burst of edits, save a draft after typing stops.

Throttle limits rate. The function runs immediately on the first call, then further calls within the interval are dropped (or the last one is deferred until the interval ends). A stream of a thousand calls a second produces, say, one execution a second. Use it when you want regular updates during a continuous stream: report progress at most once a second, however fast the work runs.

Debounce

debounce.goGo
package main

import (
	"sync"
	"time"
)

// Debouncer runs fn once, d after the most recent call to Trigger.
type Debouncer struct {
	mu    sync.Mutex
	d     time.Duration
	fn    func()
	timer *time.Timer
}

func NewDebouncer(d time.Duration, fn func()) *Debouncer {
	return &Debouncer{d: d, fn: fn}
}

// Trigger schedules fn, pushing back any call that is already scheduled.
func (b *Debouncer) Trigger() {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.timer != nil {
		b.timer.Stop()
	}
	b.timer = time.AfterFunc(b.d, b.fn)
}

// Stop cancels a pending call. It reports whether one was pending.
func (b *Debouncer) Stop() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.timer == nil {
		return false
	}
	return b.timer.Stop()
}

Trigger stops any pending timer and starts a new one; time.AfterFunc runs the function in its own goroutine when the timer fires, so fn does not block Trigger. The mutex protects the timer field, which several goroutines may reach. Stop cancels the pending run, and its return value says whether there was one to cancel: Timer.Stop returns false if the timer has already fired, which tells you the function is running or has run.

debounce_test.goGo
package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func Example_debounce() {
	var runs atomic.Int32
	d := NewDebouncer(30*time.Millisecond, func() { runs.Add(1) })

	for range 10 { // a burst: ten events, 5ms apart
		d.Trigger()
		time.Sleep(5 * time.Millisecond)
	}
	fmt.Println("right after the burst, runs:", runs.Load())

	time.Sleep(60 * time.Millisecond) // quiet: the trailing call fires
	fmt.Println("after the quiet period, runs:", runs.Load())

	// Output:
	// right after the burst, runs: 0
	// after the quiet period, runs: 1
}

Ten triggers spaced 5 ms apart, with a debounce of 30 ms, produce no run during the burst, since every trigger pushes the deadline back, and exactly one run after the quiet period. That is the behavior: the function waits until the input settles.

Throttle

A throttle runs immediately and then locks out further runs for the interval. The version below also keeps the last call that arrived during the lockout and runs it when the interval ends (trailing edge), so the final state is never lost:

throttle.goGo
package main

import (
	"sync"
	"time"
)

// Throttle runs fn at most once per d. The first call runs at once, and a call
// that arrives during the lockout runs when it ends, so the last state wins.
type Throttle struct {
	mu      sync.Mutex
	d       time.Duration
	fn      func()
	lastRun time.Time
	pending bool
	timer   *time.Timer
}

func NewThrottle(d time.Duration, fn func()) *Throttle {
	return &Throttle{d: d, fn: fn}
}

// Call requests a run of fn.
func (t *Throttle) Call() {
	t.mu.Lock()
	defer t.mu.Unlock()

	wait := t.d - time.Since(t.lastRun)
	if wait <= 0 {
		t.lastRun = time.Now()
		go t.fn() // leading edge: run now
		return
	}
	if t.pending {
		return // a trailing run is already scheduled; it will see the latest state
	}
	t.pending = true
	t.timer = time.AfterFunc(wait, func() {
		t.mu.Lock()
		t.pending = false
		t.lastRun = time.Now()
		t.mu.Unlock()
		t.fn()
	})
}

// Stop cancels a pending trailing run.
func (t *Throttle) Stop() {
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.timer != nil && t.timer.Stop() {
		t.pending = false
	}
}

Call has three outcomes. If the interval has passed since the last run, it runs fn at once (the leading edge). If not, and no trailing run is scheduled, it schedules one for the end of the interval. If one is already scheduled, it does nothing, because the scheduled run will do the work with the latest state. That last case is what makes a throttle cheap under a heavy stream: only two executions per interval at most, one leading and one trailing.

main.goGo
package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func main() {
	var runs atomic.Int32
	th := NewThrottle(50*time.Millisecond, func() { runs.Add(1) })

	start := time.Now()
	for time.Since(start) < 240*time.Millisecond {
		th.Call() // a hot stream: a call every millisecond
		time.Sleep(time.Millisecond)
	}
	time.Sleep(60 * time.Millisecond) // let the trailing call fire
	fmt.Println("calls: about 240, runs:", runs.Load(), "(at most one per 50ms)")
}
Text
calls: about 240, runs: 6 (at most one per 50ms)

About 240 calls in 240 ms became a handful of runs (six in the output above; the count varies by one or two between runs), one every 50 ms, and a final trailing run catches the last state. Compare debounce on the same stream: it would run only once, after the stream stopped, so a continuous stream would never fire it at all. That difference decides which to use: for a stream that never settles, debounce starves; throttle does not.

Tests

Both helpers are about time, so the tests use generous margins and assert on counts and ordering, not on exact moments:

helpers_test.goGo
package main

import (
	"sync/atomic"
	"testing"
	"time"
)

func TestThrottleRunsFirstCallImmediately(t *testing.T) {
	var runs atomic.Int32
	th := NewThrottle(200*time.Millisecond, func() { runs.Add(1) })

	th.Call()
	time.Sleep(20 * time.Millisecond)
	if runs.Load() != 1 {
		t.Errorf("first call should run at once, runs = %d", runs.Load())
	}
}

func TestThrottleCollapsesABurstIntoTwoRuns(t *testing.T) {
	var runs atomic.Int32
	th := NewThrottle(60*time.Millisecond, func() { runs.Add(1) })

	for range 50 {
		th.Call() // all inside one interval
	}
	time.Sleep(150 * time.Millisecond)
	if got := runs.Load(); got != 2 {
		t.Errorf("a burst in one interval should give a leading and a trailing run, got %d", got)
	}
}

func TestThrottleStopCancelsTheTrailingRun(t *testing.T) {
	var runs atomic.Int32
	th := NewThrottle(60*time.Millisecond, func() { runs.Add(1) })

	th.Call()
	th.Call() // schedules a trailing run
	th.Stop()
	time.Sleep(120 * time.Millisecond)
	if got := runs.Load(); got != 1 {
		t.Errorf("Stop should cancel the trailing run, runs = %d", got)
	}
}

func TestDebounceWaitsForQuiet(t *testing.T) {
	var runs atomic.Int32
	d := NewDebouncer(40*time.Millisecond, func() { runs.Add(1) })

	for range 5 {
		d.Trigger()
		time.Sleep(10 * time.Millisecond)
	}
	if runs.Load() != 0 {
		t.Errorf("ran during the burst, runs = %d", runs.Load())
	}
	time.Sleep(80 * time.Millisecond)
	if runs.Load() != 1 {
		t.Errorf("want exactly one run after the quiet period, runs = %d", runs.Load())
	}
}

func TestDebounceStopCancels(t *testing.T) {
	var runs atomic.Int32
	d := NewDebouncer(30*time.Millisecond, func() { runs.Add(1) })

	d.Trigger()
	if !d.Stop() {
		t.Error("Stop should report that a call was pending")
	}
	time.Sleep(60 * time.Millisecond)
	if runs.Load() != 0 {
		t.Errorf("a stopped debounce still ran, runs = %d", runs.Load())
	}
}
Text
=== RUN   TestThrottleRunsFirstCallImmediately
--- PASS: TestThrottleRunsFirstCallImmediately (0.02s)
=== RUN   TestThrottleCollapsesABurstIntoTwoRuns
--- PASS: TestThrottleCollapsesABurstIntoTwoRuns (0.15s)
=== RUN   TestThrottleStopCancelsTheTrailingRun
--- PASS: TestThrottleStopCancelsTheTrailingRun (0.12s)
=== RUN   TestDebounceWaitsForQuiet
--- PASS: TestDebounceWaitsForQuiet (0.14s)
=== RUN   TestDebounceStopCancels
--- PASS: TestDebounceStopCancels (0.06s)
=== RUN   Example_debounce
--- PASS: Example_debounce (0.12s)
PASS
ok  	example.test/lab	1.859s

The five tests pin down the semantics: throttle's leading run is immediate, a burst inside one interval yields two runs (leading and trailing), and Stop cancels the trailing run; debounce does nothing during a burst, runs once after quiet, and Stop cancels. Run them with -race; they exercise the shared state under real timers. Because they depend on wall-clock timing, they are the kind of test that benefits from testing/synctest where the helpers are structured to run inside a bubble (synctest); the margins here keep them stable without it.

Choosing the edge

Both helpers have a leading and a trailing edge, and the choice is a requirement, not a detail.

EdgeBehaviorSuits
Trailing only (debounce)Run after the burst endsReload, save, search-as-you-type
Leading onlyRun on the first event, ignore the rest for dButton presses, "notify once"
Leading and trailing (the throttle above)Run first, then once more with the final stateProgress updates, live dashboards

Leading-only drops the last event, which is wrong whenever the final state matters. Trailing-only delays the response to the first event by d, which is wrong whenever latency matters. Pick on what must not be lost and what must not be late.

Where each one shows up in real programs

A file watcher that reloads configuration should be debounced: editors write temp files, rename, and touch metadata, producing a burst of events for one logical save, and the reload should run once when they settle. A search endpoint driven by typing wants a debounce on the client and often a throttle on the server. A progress reporter wants a throttle with a trailing edge, so the display updates at a steady pace and ends on the true final value. A metrics flusher under bursty load wants a throttle so it does not flush a thousand times a second. Autosave wants both: debounce for the quiet moment, and a maximum wait so a user who types continuously still gets saved eventually. That last pattern, a debounce with a cap, is a combination of the two: use the debounce timer, but remember when the first pending event arrived, and fire once the cap is reached even if events keep coming.

Operational notes

Stop on shutdown. A pending timer outlives its owner. At shutdown, call Stop, or add a Flush that runs the pending call now, depending on whether the last event must be honoured. Mind what fn does. It runs in its own goroutine, so it must be safe to call concurrently with the next trigger; for a debounce that rebuilds shared state, serialise it with its own lock. Do not use either as a rate limiter for security. A throttle spaces calls from one source; it does not bound a client's total traffic (rate limiting does). For events on channels, the same ideas apply with a select over a timer instead of AfterFunc: receive an event, reset a timer, and act when the timer fires with no new event. The building blocks are documented in time.AfterFunc and time.Timer.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.