The debounce technique is used to delay processing an event or state until it stops firing for a period of time. It’s best used for reactive functions that depend only a current state. A common use case is debouncing form validation – say you want to show “weak password” only if the user has stopped typing out a password.
Lodash’s debounce takes a callback to be debounced and wraps it such that the callback is invoked only once for each burst of invocations less than “n” milliseconds apart. It also provides a timeout in case you need a guarantee that an invocation eventually does occur.
Parameter aggregation
This “vanilla” debounce assumes that the end application state is unaffected by whether the callback is invoked once or multiple times. This is true if the debounced callback entirely overwrites the same piece of state in your application. In a password validator, the “password strength” state is recalculated each time. The password strength doesn’t depend on past values of any other state. That’s why the validator can be safely be debounced.
If you use debounce as a general-purpose optimization, you’ll find that this assumption is often false. A simple example is a settings page with multiple toggles that each map to a different setting in the database.
const updateSettings = debounce((diff/*: Partial<{
username: string,
password: string,
}>*/) => {
fetch('/v1/api/settings', {
method: 'POST'
body: JSON.stringify(diff),
})
})
Say the user changes their username and then password. The above debounced callback will save the last change − their password, but not their username. A correct implementation here would aggregate the modifications on each invocation and send the aggregate changes to the server at the end. I’ve seen this mistake quite a few times. For another practial example, a debounced undo / redo function will not behave the way you’d want it to (but that’s a not so subtle compared to the settings example!)
I propose an alternate debounce implementation that accepts an aggregator. The aggregator is a callback that is not debounced ands merges the arguments for each invocation in a lightweight fashion. The aggregator equivalent to no aggregation would be:
const no_aggregation = (previousArguments, arguments) => arguments
debounce(passwordValidator, {
aggregator: no_aggregation, // or optional
});
But in the case of the settings updates, you would do a deep merge of each diff:
const updateSettings = debounce((diff/*: Partial<{
username: string,
password: string,
}>*/) => {
fetch('/v1/api/settings', {
method: 'POST'
body: JSON.stringify(diff),
})
}, {
aggregate: (previousDiff, diff) => ({
...previousDiff,
...diff
})
})
This should work for most use-cases. It’s important to make sure the debounced callback doesn’t read global state because that cannot be aggregated easily.
Partitioned debouncing
An interesting puzzle for me was when I added group selection for a 2D editor. I debounced sending new item positions to the server, which worked well for the single-item editor use-case:
const save = debounce((itemId, x, y) => {
server.send('position-changed', { itemId, x, y })
})
However, when I enabled multiple item selection and dragged a group around − each item would end up in the wrong place (save for one). In hindsight, this “obviously” was because debouncing individual saves meant that each only 1 furniture would end up being saved at a time.
A way to solve this is debouncing saves per item instead of across all saves. Theoretically, we could’ve applied this to the settings example too − debouncing the saving of each setting individually (although you’d end up with multiple network requests)
A powerful debounce implementation could accept a “partitioning” callback that returns a string unique to contexts debounced individually. Somewhat as follows:
const save = debounce((itemId, x, y) => {
server.send('position-changed', { itemId, x, y })
}, {
partitionBy: (itemId) => itemId
})
The implementation would internally map each partition string to independent timers.
Another practical use-case for this this would be a message queue where you need to debounce messages partitioned by message type or user, so that they can be rate limited.
Debounce ≠ Async queue
Another misassumption I’ve seen is that debounced async callbacks won’t have concurrent executions.
Say, for example, that you are debouncing a toggle that opens a new tab. To open the tab, some data must be loaded asynchronously. The reverse is not true and the tab is closed synchronously. You’ve chosen to debounce this toggle to prevent double or triple clicks from borking the application.
Now, what happens when the user clicks on the toggle twice (but not double click)
- First click, toggle on
- Debounce timeout
- Data starts being loaded
- Second click, toggle off
- Debounce timeout
- Tab isn’t open yet, so second click does nothing
- Data loaded, tab opened, & user annoyed 😛
The expected behavior was for the tab to close or not open after the second click. This issue is not related to debouncing − you need to either cancel the first async operation and if that’s not possible, each click needs to be processed off a queue. That way the second click is processed once the data has loaded.
This is a bad example, however, because it’s easy to simply cancel rendering a tab. In distributed applications, this isn’t necessarily the case because messages could be processed on some unknown server.
An async debounce would wait for the last invocation to resolve before doing another invocation. A rough implementation would be as follows:
let promise = null;
let queued = false;
function debounced_process() {
if (!promise)
let thisPromise;
thisPromise = promise = process().then(() => {
if (promise === thisPromise) // reset unless queued
promise = null;
});
else if (!queued) {
queued = true;
let thisPromise;
thisPromise = promise = promise.then(() => {
queued = false;
return process();
}).then(() => {
if (promise === thisPromise)
promise = null;
});
} else {
// We've already queued "process" to be called again after
// the current invocation. We shouldn't queue it again.
}
}
This specialized debounce is perhaps better than the vanilla debounce for long-running async tasks.