Debounce in JavaScript and Go
Gopher bouncing a ball off a JavaScript wall

Debounce in JavaScript and Go

What is debounce by example

Adding city suggestions to a form field is a good example of why naive implementations fail: calling the API on every keystroke can turn into a self-imposed DDoS. A better approach is to wait for a pause in typing — say, 1 second — before firing the request, resetting the timer on each keystroke. This pattern is called debounce.

JavaScript solution

JS is single-threaded and Event Loop guarantees that only one function is executed at a time. Because of that we don’t need to worry about race conditions.

// Takes a function as an input and returns debounced version of the function
function debounce(fn, wait) {
    let timeoutId;

    return function(...args) {
        clearTimeout(timeoutId);

        timeoutId = setTimeout(() => {
            fn.apply(this, args);
        }, wait)
    }
}        

Go solution

Go's GMP scheduler makes execution order non-deterministic, so any debounce implementation needs a synchronization mechanism. We also can't easily accept arbitrary arguments and forward them to any function.

We use time.AfterFunc — it avoids explicit synchronization in user code, since the runtime handles timer scheduling internally, and unlike channel-based timers, it doesn't require channel draining.

func debounceAfterFunc(fn func(), wait time.Duration) func() {
 timer := time.AfterFunc(0, fn)
 timer.Stop()

 return func() {
  timer.Reset(wait)
 }
}        

One edge case: if Reset is called at the exact moment the timer fires, fn may execute twice. In Go ≤ 1.22, calling .Stop() before .Reset() is recommended to avoid this — though for input debouncing, the race is harmless in practice. Go 1.23 fixed the underlying issue, making the stop-before-reset pattern unnecessary.

Handling arguments

In JavaScript we can forward any arguments to the wrapped function effortlessly thanks to rest parameters (...args) and Function.prototype.apply. Go's type system doesn't allow this out of the box — a func() signature accepts no arguments, and any variadics would lose all type safety.

Easiest solution is to use closure:

debounced := debounce(func() {
    fetchCities(inputValue) // inputValue is captured from the outer scope
}, 300*time.Millisecond)        

This works well for the typical debounce use case (e.g. a UI input handler) because the function is always called with the latest value anyway — exactly what the closure captures.

If you need to support arbitrary argument types, your options are generics with a struct wrapper, code generation, or reflection — each adding complexity that the closure approach avoids entirely.

Conclusion

Debounce implementation is straightforward in both languages. JavaScript’s single-threaded model makes it trivial to reason about — no synchronization needed. Go requires more care, but the standard library provides all the necessary tools.

If you’re targeting Go 1.23+, time.AfterFunc is the cleanest solution: no channels, no context, no manual draining. For older versions, time.AfterFunc still works, but .Stop() must be called before .Reset() — otherwise if the timer fires at the exact moment of reset, fn may be called twice. For channel-based timers (NewTimer), an additional manual drain of timer.C is required after .Stop() before calling .Reset().

To view or add a comment, sign in

Others also viewed

Explore content categories