Go

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+). Stop and Reset now 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 write if !t.Stop() { <-t.C } before Reset, or a stale tick would fire an early emission.
  • The nil-channel trick (timerC = nil) disables a select case.
  • Calling time.After inside a hot loop allocates a timer each iteration.
  • Throttling with a time.Ticker gives 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

All 16 Classic Concurrency Coding Problems questions