Debounce and Throttle — Specification¶
Table of Contents¶
- Introduction
- Formal Definitions
- Debounce — Mathematical Model
- Throttle — Mathematical Model
- Token-Bucket Algorithm
- Leaky-Bucket Algorithm
golang.org/x/time/ratePackage Surfacerate.LimitType and Constructorsrate.LimiterTypeAllow,AllowNWait,WaitNReserve,ReserveN- Internal State Machine
time.AfterFuncandtime.Timer.ResetSemanticstime.TickerSemantics- Memory Model Implications
- Numerical Limits and Edge Cases
- Standard Patterns and Their Complexity
- Comparison Table — Debounce vs Throttle
- Compatibility and Version History
- References
Introduction¶
Debounce and throttle are two operators on an event stream. Both reduce a high-frequency input to a lower-frequency output, but they do so under different rules and produce observably different sequences. This document is the reference for those rules: the formal definitions, the algorithms behind production-grade implementations (in particular the token bucket inside golang.org/x/time/rate), and the public APIs that Go programs reach for.
The normative sources are:
pkg.go.dev/time—time.Timer,time.Ticker,time.AfterFunc, andtime.Now.pkg.go.dev/golang.org/x/time/rate— the canonical token-bucket implementation in the Go ecosystem.go.dev/ref/mem— the memory model that governs visibility of clock reads and timer fires across goroutines.
Several behaviours of debounce and throttle are implementation choices rather than standardised behaviour: leading vs trailing edge, drop vs queue vs block on overflow, monotonic vs wall-clock measurement. This file calls those choices out as they arise.
Formal Definitions¶
Let E = (e_1, e_2, e_3, ...) be an event stream with associated arrival timestamps t_1 <= t_2 <= t_3 <= .... A concurrency operator transforms E into an output stream O = (o_1, o_2, ...) of fire times f_1 < f_2 < ....
Debounce¶
A debouncer with wait duration w > 0 produces an output event at time f_k if and only if:
In words: a debouncer fires w time units after the last event of a quiet-period-terminated burst. If a new event arrives during the wait, the timer resets. The number of output events equals the number of bursts, not the number of input events.
Variants:
- Trailing-edge debouncer (default). Fires at
t_j + w. The payload is the last event's value. - Leading-edge debouncer. Fires at
t_jift_j - t_{j-1} > w(orj = 1). Subsequent events in the burst are ignored. - Leading + trailing debouncer. Fires twice: once at burst start, once at burst end. Suppresses internal events.
- Max-wait variant. Forces a fire after
W >= wtime even if events keep arriving — bounds latency for an event that keeps the burst alive forever.
Throttle¶
A throttler with rate r events per second and burst capacity b permits the output stream to satisfy:
That is, over any window of length L, the number of fired events does not exceed r * L + b. The constant b allows short bursts above the steady rate.
This is the formal property guaranteed by the token bucket: the bucket fills at rate r tokens per second, holds at most b tokens, and an output event requires (and consumes) one token. The leaky bucket guarantees the same envelope with a different internal mechanism.
Debounce — Mathematical Model¶
A trailing-edge debouncer can be modelled as a finite-state machine with two states (Idle, Pending) and a timer:
state := Idle
timer := nil
on event(e):
if state == Idle:
state := Pending
timer := after(w, fire)
else:
timer.Reset(w)
pending_value := e
on timer fires:
emit(pending_value)
state := Idle
on cancel:
timer.Stop()
state := Idle
The wait w is the idle deadline. Reset semantics matter: time.Timer.Reset must follow Stop plus drain to be safe (see time.AfterFunc and time.Timer.Reset Semantics).
Output rate bound¶
A trailing debouncer with wait w produces at most 1/w events per second in the worst case (a burst followed by exactly w of silence, then another burst, etc.). It produces zero events if input never goes quiet for w. This is the latency–throughput trade-off: small w means fast response, large w means fewer fires.
Max-wait extension¶
To bound latency when the input never goes quiet, a max-wait W is added:
on event(e):
if state == Idle:
state := Pending
idle_deadline := now + w
max_deadline := now + W
timer := after(min(w, W), fire)
else:
idle_deadline := now + w
timer := after(min(idle_deadline, max_deadline) - now, fire)
pending_value := e
This is sometimes called a debounce with max-wait or simply a throttle-flavoured debouncer in front-end frameworks.
Throttle — Mathematical Model¶
A throttler is parameterised by a rate r and a burst b. Several disciplines exist:
| Discipline | Behaviour on overflow |
|---|---|
| Drop | Reject the event. The caller learns it was rejected. |
| Queue | Hold the event until a slot opens. The caller waits or proceeds. |
| Block | Make the calling goroutine sleep until allowed. Equivalent to Queue with a queue of one. |
| Sample | Keep at most one event per slot; drop the rest. Like Drop but emits the latest value seen. |
The token-bucket algorithm naturally supports all four:
Allow()— Drop semantics; returnsfalsewhen no token is available.Wait(ctx)— Block semantics; sleeps until a token frees up or the context cancels.Reserve()— Queue semantics; returns a reservation describing how long to wait.
Output rate bound (steady state)¶
For input t_1, t_2, ..., t_n arriving uniformly at rate R > r, a token-bucket throttler emits at rate exactly r (after the initial burst is consumed). The buffer length under a queue discipline grows linearly with (R - r) * t until something else bounds it.
Latency¶
Under the Block discipline, the per-event latency under steady overload is (R - r) / r * (t - t_0), which is unbounded over time. Always pair with a context deadline or an upper bound on queue length.
Token-Bucket Algorithm¶
The classical token bucket has four operational rules:
- The bucket holds at most
btokens. - Tokens are added at rate
rper second, up to capacityb. (Capacity is hard.) - An event requires one token and removes it from the bucket.
- If no token is available, the event is not permitted (drop) or waits until a token arrives (queue/block).
Discrete-time formulation¶
Let T(t) denote the token count at time t. The dynamics are:
where consumed(t, t+dt) is the number of events fired in the interval. The min enforces the cap; consumption is non-negative.
Lazy implementation (used by rate.Limiter)¶
Rather than maintaining a timer that adds tokens periodically, rate.Limiter computes the bucket lazily:
on Allow() called at time now:
elapsed := now - last_update
tokens := min(b, tokens + elapsed * r)
last_update := now
if tokens >= 1:
tokens -= 1
return true
return false
The lazy form has two virtues: it does no work between calls, and it produces exactly the same envelope as the eager form. The cost is one time.Now() per call.
Reservation form¶
Reserve produces a Reservation describing how long the caller must wait for one token. The mathematical idea:
needed := 1
shortfall := max(0, needed - tokens)
wait := shortfall / r
tokens -= needed // can go negative
last_update := now
After Reserve, the bucket may be negative; tokens will accumulate before the next reservation can succeed. A Cancel on the reservation refunds the token (subject to a small adjustment for time that has passed).
Burst envelope¶
A token bucket with rate r and burst b guarantees that, over any time interval L, no more than r * L + b events are emitted. The + b term captures the burst capacity — at most b events can pile out at once.
Leaky-Bucket Algorithm¶
The leaky bucket is the dual: events are added to a fixed-size queue at any rate, and they leak out at exactly rate r. The queue length is bounded by b.
on event:
if len(queue) < b:
queue.append(event)
else:
drop
every 1/r seconds:
if len(queue) > 0:
emit(queue.pop_front())
Output is smooth (every 1/r seconds, exactly), whereas the token bucket allows bursts up to b at once. The two algorithms have the same long-run rate bound but different short-window behaviour. Most rate limiters in Go use the token bucket because callers prefer to fire b events back-to-back when capacity is available.
golang.org/x/time/rate Package Surface¶
The package is part of the golang.org/x/time extension repository, maintained by the Go team. Imported as:
The public surface as of the current release:
type Limit float64
const Inf Limit = math.MaxFloat64
func Every(interval time.Duration) Limit
func (l Limit) String() string
type Limiter struct { /* unexported */ }
func NewLimiter(r Limit, b int) *Limiter
func (lim *Limiter) Limit() Limit
func (lim *Limiter) Burst() int
func (lim *Limiter) Tokens() float64
func (lim *Limiter) TokensAt(t time.Time) float64
func (lim *Limiter) SetLimit(newLimit Limit)
func (lim *Limiter) SetLimitAt(t time.Time, newLimit Limit)
func (lim *Limiter) SetBurst(newBurst int)
func (lim *Limiter) SetBurstAt(t time.Time, newBurst int)
func (lim *Limiter) Allow() bool
func (lim *Limiter) AllowN(t time.Time, n int) bool
func (lim *Limiter) Wait(ctx context.Context) error
func (lim *Limiter) WaitN(ctx context.Context, n int) error
func (lim *Limiter) Reserve() *Reservation
func (lim *Limiter) ReserveN(t time.Time, n int) *Reservation
type Reservation struct { /* unexported */ }
func (r *Reservation) OK() bool
func (r *Reservation) Delay() time.Duration
func (r *Reservation) DelayFrom(t time.Time) time.Duration
func (r *Reservation) Cancel()
func (r *Reservation) CancelAt(t time.Time)
type Sometimes struct {
First int
Every int
Interval time.Duration
// unexported fields
}
func (s *Sometimes) Do(f func())
All Limiter methods are safe for concurrent use. Locks are held only for the duration of a token-count update — never across a time.Now call to user code.
rate.Limit Type and Constructors¶
rate.Limit is a float64 measured in events per second. The package's documentation:
Limit defines the maximum frequency of some events. Limit is represented as number of events per second. A zero Limit allows no events.
Special values¶
rate.Inf— effectively infinite rate. The limiter never blocks; everyAllowreturnstrue. Useful as a "rate limiting disabled" sentinel.0(literal zero) — allows zero events per second. EveryAllowreturnsfalse. Useful for "currently paused".
Every(d time.Duration) Limit¶
Every converts a minimum time interval between events to a Limit.
rate.Every(100 * time.Millisecond) // 10 events/second
rate.Every(1 * time.Second) // 1 event/second
rate.Every(0) // rate.Inf
Internally Every(d) = 1 / d.Seconds() for d > 0. Negative d panics in the package's caller (use a positive duration).
Choosing units¶
The rate is events per second, not per minute or per hour. Two events per minute is rate.Every(30 * time.Second) or equivalently rate.Limit(1.0 / 30.0). Keep the units in the type by preferring Every over the float literal where the source value is a duration.
rate.Limiter Type¶
rate.Limiter is the canonical token bucket. The constructor:
NewLimiter returns a new Limiter that allows events up to rate r and permits bursts of at most b tokens.
Invariants¶
ris aLimit(events/second); negative is allowed but pathological.b >= 0. A burst of zero withr > 0means tokens are added but the cap is zero, so no token is ever held —Allowwill almost always returnfalse(the limiter could only allow at the precise instant a token is added, then the cap clips it to zero). Avoidb = 0.- The limiter starts full —
btokens already in the bucket. This is sometimes surprising; the firstbcalls succeed regardless of rate.
Reading state¶
lim.Limit() Limit // current rate
lim.Burst() int // current capacity
lim.Tokens() float64 // current token count (estimate at time.Now)
Tokens() is an estimate — the value is computed lazily, so a concurrent caller may observe a slightly different reading. The number is for diagnostics; do not branch on it for correctness.
Mutating state¶
Both are safe under concurrent use and take effect as if the change had occurred at time.Now. The *At variants let tests inject a specific instant.
Allow, AllowN¶
Allow reports whether an event may happen now. AllowN reports whether n events may happen at time t.
Behaviour:
- Returns
trueand consumes the tokens ifntokens are available. - Returns
falseand consumes nothing if fewer thanntokens are available. - Never blocks. Always returns immediately.
This is the drop discipline. Typical usage:
AllowN is for events that "weigh" more than one — for example, a write of n bytes counted against a bytes-per-second limit.
Edge cases¶
AllowN(t, 0)returnstrueand does nothing.AllowN(t, n)wheren > burstreturnsfalseregardless of token state (the bucket cannot holdntokens, so the request can never be satisfied — the package fails fast).tin the past returnsfalse. The package's source has a check: ift.Before(lim.last), the call is treated ast == lim.last. (This avoids time travel under monotonic-clock anomalies.)
Wait, WaitN¶
func (lim *Limiter) Wait(ctx context.Context) error
func (lim *Limiter) WaitN(ctx context.Context, n int) error
Wait is shorthand for WaitN(ctx, 1). WaitN blocks until lim permits n events to happen. It returns an error if n exceeds the Limiter's burst size, the Context is canceled, or the expected wait time exceeds the Context's Deadline.
Behaviour:
- Computes the time at which
ntokens will be available, sleeps that long, and returnsnil. - If
ctxis cancelled before the wait completes, the reservation is cancelled (tokens refunded) and the function returnsctx.Err(). - If
n > burst, returns an error immediately ("rate: Wait(n=%d) exceeds limiter's burst %d"). - If the wait would exceed
ctx.Deadline(), returns an error immediately without sleeping.
The block discipline. Typical usage:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
if err := lim.Wait(ctx); err != nil {
return err
}
process()
Why the deadline check matters¶
Without it, a caller with a 100-ms deadline might enter Wait for two seconds, consume a token, sleep, only to have the deadline fire. The token would still be consumed — wasted. The package's preflight saves both the caller and other goroutines from that waste.
Reserve, ReserveN¶
func (lim *Limiter) Reserve() *Reservation
func (lim *Limiter) ReserveN(t time.Time, n int) *Reservation
Reserve is shorthand for ReserveN(time.Now(), 1). ReserveN returns a Reservation that indicates how long the caller must wait before n events happen. The Limiter takes this Reservation into account when allowing future events.
A Reservation carries:
r.OK()—trueif the reservation can be honoured (i.e.,n <= burst).r.Delay()— duration the caller should wait before acting.r.Cancel()— release the reservation, refunding tokens (subject to time elapsed).
Typical pattern:
r := lim.Reserve()
if !r.OK() {
return errors.New("limiter cannot satisfy")
}
time.Sleep(r.Delay())
process()
Or, more usefully, integrate with a select:
r := lim.Reserve()
if !r.OK() {
return errors.New("limiter cannot satisfy")
}
select {
case <-time.After(r.Delay()):
process()
case <-ctx.Done():
r.Cancel()
return ctx.Err()
}
Why Reserve exists¶
Allow cannot wait, and Wait cannot integrate cleanly with other channels (it hides the sleep). Reserve exposes the delay so the caller can use a select to drop, queue, or block by choice.
Reservation cancellation semantics¶
When you call r.Cancel() before the reservation's deadline:
tokens += n // refund
// minus any tokens that would have accumulated during the elapsed time
// (the bucket goes back to where it would have been had we never reserved)
After the deadline, Cancel is a no-op — the reservation is considered consumed.
This makes Reserve cancellation-safe under context termination: cancel-and-refund correctly accounts for time that elapsed.
Internal State Machine¶
rate.Limiter internally stores:
type Limiter struct {
mu sync.Mutex
limit Limit // current rate
burst int // current capacity
tokens float64 // current token count (lazy)
last time.Time // last time tokens was updated
lastEvent time.Time // last time an event was reserved or allowed
}
The lazy update on every method call:
func (lim *Limiter) advance(t time.Time) (newT time.Time, newTokens float64) {
last := lim.last
if t.Before(last) {
last = t
}
elapsed := t.Sub(last)
delta := lim.limit.tokensFromDuration(elapsed)
tokens := lim.tokens + delta
if burst := float64(lim.burst); tokens > burst {
tokens = burst
}
return t, tokens
}
Two important properties:
- Monotonic clamp. If
tis in the past relative tolast, the call is clamped tolast. This prevents the bucket from "going back in time" if a caller passes an old timestamp. - Burst clamp. Tokens never exceed
burst. Long idle periods do not grow the bucket beyondb.
The bucket can go below zero during reservations — that represents a deferred consumption. Allow and Wait never leave tokens negative; only Reserve does, and the deficit is paid down at rate r.
time.AfterFunc and time.Timer.Reset Semantics¶
A debouncer's most-used primitive is time.AfterFunc. The documentation:
AfterFunc waits for the duration to elapse and then calls f in its own goroutine. It returns a Timer that can be used to cancel the call using its Stop method. Reset changes the timer to expire after duration d. It returns true if the timer had been active, false if the timer had expired or been stopped.
Pre-Go 1.23: drain-then-reset¶
Before Go 1.23, the only safe Reset pattern was:
The drain is mandatory because Stop returning false could mean the timer either fired (channel has a value) or was already stopped (channel is empty). For AfterFunc timers (which have no C channel), the drain step is omitted.
Go 1.23+: simpler Reset¶
Go 1.23 changed time.Timer so that calling Stop or Reset guarantees the channel is drained. The new safe pattern:
The old code remains correct; the new code is simpler.
Concurrent fires¶
AfterFunc invokes f in its own goroutine. Concurrent calls to Stop/Reset and the fire are race-free at the time package level — but f itself runs concurrently with the caller. A debouncer must serialise its state under a mutex if f touches it.
time.Ticker Semantics¶
type Ticker struct {
C <-chan Time
// contains filtered or unexported fields
}
func NewTicker(d Duration) *Ticker
func (t *Ticker) Stop()
func (t *Ticker) Reset(d Duration)
Behaviour:
Creceives the current time at every tick. Capacity is 1.- If the receiver is slow, ticks are dropped, not buffered. The channel never holds more than one tick.
Stopdoes not closeC. AfterStop, no further ticks arrive, but any tick already in the channel remains.Resetchanges the tick period without restarting the ticker.
Implications for throttle:
for range tick.C { ... }is the simplest throttle, but loop bodies that take longer thandwill skip ticks. The throttle becomes "no more often thand", not "exactly everyd".time.Tickerwithdclose to the timer resolution (millisecond or less on most OSes) is inaccurate. For sub-millisecond rates, usetime.Now()-driven loops instead.
Comparison to time.After¶
time.After(d) allocates a new Timer each call. Inside a loop, this leaks until expiry. time.NewTicker allocates once and is reused. Always prefer Ticker for repeated waits.
Memory Model Implications¶
From go.dev/ref/mem:
The completion of any read of a variable v happens before the completion of any subsequent read or write of v in the same goroutine.
Cross-goroutine, the relevant rules for debounce and throttle:
- Timer fire happens-after Reset/Stop. A timer that fires synchronises with the goroutine that started it, but only through the channel send. A subsequent
Resetin another goroutine has no happens-before relationship with the timer'sfunless an explicit synchronisation (mutex, channel) bridges them. time.Now()is not a synchronisation primitive. Two goroutines readingtime.Now()may see non-monotonic values relative to each other, even on systems where each goroutine sees a monotonic clock individually. Always use a real synchronisation primitive when ordering matters.rate.Limiteris safe for concurrent use. Its internal mutex establishes happens-before between callers.
A debouncer that fires f from AfterFunc and is Reset from a different goroutine must hold a mutex over its state. The mutex provides both correctness (state stays consistent) and visibility (changes are seen).
Numerical Limits and Edge Cases¶
| Quantity | Limit | Notes |
|---|---|---|
rate.Limit representable rate | [0, math.MaxFloat64] | rate.Inf is MaxFloat64. |
rate.Limiter.burst | [0, math.MaxInt] | Effectively unbounded. 0 is degenerate. |
time.Duration resolution | 1 ns | But OS timer resolution is ~1 ms on most platforms. |
| Token count | [-inf, burst] | Can be negative under outstanding reservations. |
time.Now() resolution | Per OS | Linux: ~1 ns (CLOCK_MONOTONIC). macOS: ~1 us. Windows: ~16 ms unless overridden. |
Subtleties¶
- Negative
Limit. Permitted by the type but pathological. The bucket fills at a negative rate, meaning tokens are consumed over time. Avoid. Infvs huge finite rate. Withrate.Inf, the package short-circuits — everyAllowreturnstruewithout touching the token count. A very large finiteLimit(1e9) still computes tokens but they cap atburstinstantly. UseInffor "no limit".- Wallclock vs monotonic. Since Go 1.9,
time.Now()carries both readings.rate.Limiteruses monotonic clock subtraction internally, so adjustments to the wallclock (NTP step, manual change) do not break the limiter.
Standard Patterns and Their Complexity¶
| Pattern | Allocation per event | Complexity | Goroutines |
|---|---|---|---|
lim.Allow() (drop) | 0 | O(1) | 0 |
lim.Wait(ctx) (block) | 1 timer + 1 channel | O(1) | 0 (sleeps in place) |
lim.Reserve() (queue) | 1 reservation | O(1) | 0 |
Trailing debouncer with AfterFunc | 1 timer per burst | O(1) per event under mutex | 0 (fire reuses runtime goroutine) |
| Trailing debouncer with goroutine | 1 goroutine per debouncer | O(1) per event | 1 |
Naive time.After-based throttle in loop | 1 timer per iteration | O(1) per event | 0 |
time.NewTicker-based throttle in loop | 1 ticker amortised | O(1) per event | 0 |
| Per-actor throttle (map of limiters) | 1 limiter per actor | O(log N) lookup or O(1) hash | 0 |
rate.Limiter is allocation-free under Allow/AllowN. Wait allocates only if it needs to sleep. Reserve allocates a Reservation struct (16 bytes on most platforms).
Comparison Table — Debounce vs Throttle¶
| Property | Debounce | Throttle |
|---|---|---|
| Question it answers | "Has the input stopped?" | "Am I emitting too fast?" |
| Output rate (worst case) | At most 1/w events/second | At most r * L + b over any L |
| Output rate (no input) | 0 | 0 |
| Output rate (constant input) | Possibly 0 (never silent) | Exactly r (steady state) |
| Latency from event to fire | Up to w | 0 (drop), 1/r typical (queue) |
| Best-suited input | Bursty (search keystrokes, resize) | Uniform high-rate (API calls, logs) |
| Primitive | time.AfterFunc + Reset | time.Ticker or rate.Limiter |
| Output payload | The last event of the burst | Each permitted event |
| Number of output events | One per burst | One per token consumed |
| State | Pending timer | Bucket (tokens, last update) |
Compatibility and Version History¶
| Component | Version | Change |
|---|---|---|
time.Timer.Reset | Go 1.0 | Introduced. |
time.AfterFunc | Go 1.0 | Introduced. |
time.Now monotonic component | Go 1.9 | Wall-clock and monotonic readings combined. |
golang.org/x/time/rate | Initial commit 2015 | Token-bucket limiter. |
rate.Limiter.SetBurst | Added later (2017) | Pair with SetLimit for dynamic adjustment. |
rate.Limiter.TokensAt | Added later (2019) | Diagnostic without time travel. |
rate.Sometimes | Added 2022 | Conditional throttled call. |
time.Timer channel drain on Stop | Go 1.23 | Reset becomes simpler; older code still safe. |
| Garbage-collectable unreferenced timers | Go 1.23 | A *Timer that goes out of scope no longer pins runtime memory. |
The Go 1 compatibility promise covers time. The golang.org/x/time/rate package is not part of the standard library and is governed by the golang.org/x looser compatibility rules — but in practice the API has been stable since 2015.
References¶
timepackage — https://pkg.go.dev/timetime.Timer.Resetsemantics — https://pkg.go.dev/time#Timer.Resettime.Ticker— https://pkg.go.dev/time#Tickergolang.org/x/time/ratepackage — https://pkg.go.dev/golang.org/x/time/raterate.Limiter— https://pkg.go.dev/golang.org/x/time/rate#Limiterrate.Sometimes— https://pkg.go.dev/golang.org/x/time/rate#Sometimes- The Go Memory Model — https://go.dev/ref/mem
- Go 1.23 release notes —
timepackage — https://go.dev/doc/go1.23#timereleasetime - Token-bucket algorithm (Wikipedia) — https://en.wikipedia.org/wiki/Token_bucket
- Leaky-bucket algorithm (Wikipedia) — https://en.wikipedia.org/wiki/Leaky_bucket
- RFC 2698 — Two Rate Three Color Marker (related rate-limiting algorithm) — https://datatracker.ietf.org/doc/html/rfc2698
- John Graham-Cumming — How to throttle a Go function (Cloudflare engineering blog) — https://blog.cloudflare.com/
- Bryan Boreham — Rate limiting with Go (Weaveworks) — https://www.weave.works/blog/
- Source:
golang.org/x/time/rate/rate.go— https://cs.opensource.google/go/x/time/+/master:rate/rate.go