Implement a debouncer and a throttler for a stream of events using time.Timer and channels.
Question 527HardGo 1.22 to 1.25
Debounce emits the last event only after a quiet period of d. Every new event restarts the timer. Throttle emits at most one event per interval, taking the first one (leading edge) and dropping the rest.
func Debounce[T any](ctx context.Context, in <-chan T, d time.Duration) <-chan T {
out := make(chan T)
go func() {
defer close(out)
var (
timer *time.Timer
timerC <-chan time.Time // nil channel = case disabled
last T
pending bool
)
for {
select {
case v, ok := <-in:
if !ok { // flush trailing event on close
if pending {
select {
case out <- last:
case <-ctx.Done():
}
}
return
}
last, pending = v, true
if timer == nil {
timer = time.NewTimer(d)
} else {
timer.Reset(d) // safe without draining in Go 1.23+
}
timerC = timer.C
case <-timerC:
timerC, pending = nil, false
select {
case out <- last:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return out
}
func Throttle[T any](ctx context.Context, in <-chan T, every time.Duration) <-chan T {
out := make(chan T)
go func() {
defer close(out)
var next time.Time
for {
select {
case v, ok := <-in:
if !ok {
return
}
if now := time.Now(); !now.Before(next) {
next = now.Add(every)
select {
case out <- v:
case <-ctx.Done():
return
}
} // else: drop
case <-ctx.Done():
return
}
}
}()
return out
}
Key gotchas:
- Timer semantics changed in Go 1.23 (for modules declaring
go 1.23+).StopandResetnow guarantee that no stale tick is received afterwards, and timers are garbage collected even if they are never stopped. Before 1.23 you had to writeif !t.Stop() { <-t.C }beforeReset, or a stale tick would fire an early emission. - The nil-channel trick (
timerC = nil) disables aselectcase. - Calling
time.Afterinside a hot loop allocates a timer each iteration. - Throttling with a
time.Tickergives fixed windows rather than a sliding interval.
For production rate limiting use golang.org/x/time/rate (token bucket, with Wait(ctx) and Allow()).
More on Classic Concurrency Coding Problems
- Q525Concurrently crawl URLs (the Tour of Go web crawler) with depth limits, deduplication and bounded parallelism.
- Q526Count word frequencies in a large set of files concurrently and return the top K words. How do you merge per-worker maps efficiently?
- Q528Implement a sharded concurrent map (N shards with separate mutexes). When does it beat sync.Map and a single RWMutex?
- Q529Implement a readers-writer lock that prefers writers using only channels or sync.Mutex. Why is it hard to get right?
- Q530Implement a retry-with-timeout helper that runs a function in a goroutine and returns early when the per-attempt or overall deadline expires, without leaking goroutines.
- Q531Implement a heartbeat/watchdog: a worker must signal liveness every T, or a supervisor restarts it.