smithers' bloggo

Debouncing with promises

I recently discovered that "debouncing" gets unexpectedly tricky when promises are involved.

Consider, if you will, a user input field. As the user types, it queries the server to check for uniqueness of the value, and then displays whether or not the value is unique. Here is the happy path:

happy path

A keypress triggers a server fetch. When the server fetch resolves, the UI is updated.

However, the user will press many keys and we can't have our server flooded with unnecessary requests. So, of course, we need to debounce these events!

debounced happy path

But here you might notice that we haven't fully thought through the debouncing of this asynchronous server request. Consider the next scenario, where server fetches overlap.

overlapping requests

The server has responded to requests in a different order, and the UI is left showing results according to stale user input. This is bad!

There is a surprisingly popular npm module called debounce-promise with more than 150,000 weekly downloads as of Feb 2021. But it doesn't quite meet the needs of this problem. We want to update the UI according to the last initiated promise, ignoring all others. In contrast, debounce-promise resolves all accumulated promises to a shared result.

Only one promise should truly resolve. Other "debounced" promises should be skipped somehow, such as by resolving to a special value of "skipped" the instant we know we want to ignore it.

Well, I came up with my own solution. Its promise-resolving behavior is depicted here.

custom solution

And it is implemented as follows.

import debounce from 'lodash/debounce'; /* any debounce implementation will do */

export function debouncePromise<A, B>(func: (a: A) => Promise<B>, debounceDelay?: number): (a: A) => Promise<B | 'skipped'> {
const promiseResolverRef: { current: (b: B | 'skipped') => void } = {
current: () => {},
};

const debouncedFunc = debounce((a: A) => {
const promiseResolverSnapshot = promiseResolverRef.current;
func(a).then((b) => {
if (promiseResolverSnapshot === promiseResolverRef.current) {
promiseResolverRef.current(b);
}
});
}, debounceDelay);

return (a: A) => new Promise<B | 'skipped'>((resolve) => {
promiseResolverRef.current('skipped');
promiseResolverRef.current = resolve;

debouncedFunc(a);
});
}

And here's an example usage in React.

const debouncedServerFetch = debouncePromise(serverFetch);

function Example() {
const [serverResult, setServerResult] = useState();
return (
<input
onChange={async (e) => {
const result = await debouncedServerFetch(e.target.value);
if (result === 'skipped') {
return;
}
setServerResult(result);
}}

/>

);
}

The end.