35

I'm trying to implement a debounce function that works with a promise in javascript. That way, each caller can consume the result of the "debounced" function using a Promise. Here is the best I have been able to come up with so far:

function debounce(inner, ms = 0) {
  let timer = null;
  let promise = null;
  const events = new EventEmitter();  // do I really need this?

  return function (...args) {
    if (timer == null) {
      promise = new Promise(resolve => {
        events.once('done', resolve);
      });
    } else {
      clearTimeout(timer);
    }

    timer = setTimeout(() => {
      events.emit('done', inner(...args));
      timer = null;
    }, ms);

    return promise;
  };
}

Ideally, I would like to implement this utility function without introducing a dependency on EventEmitter (or implementing my own basic version of EventEmitter), but I can't think of a way to do it. Any thoughts?

Chris
  • 945
  • 2
  • 12
  • 18
  • 1
    how is `debounce` function going to be used? – Aprillion Feb 05 '16 at 15:46
  • 2
    @Aprillion It is used as a standard debounce function, like the debounce function provided by [lodash](https://lodash.com/docs#debounce), except I want the callers to have access to the return value of the inner function via a promise. Does that answer your question? I can elaborate further if not. – Chris Feb 05 '16 at 15:59

9 Answers9

36

I found a better way to implement this with promises:

function debounce(inner, ms = 0) {
  let timer = null;
  let resolves = [];

  return function (...args) {    
    // Run the function after a certain amount of time
    clearTimeout(timer);
    timer = setTimeout(() => {
      // Get the result of the inner function, then apply it to the resolve function of
      // each promise that has been created since the last time the inner function was run
      let result = inner(...args);
      resolves.forEach(r => r(result));
      resolves = [];
    }, ms);

    return new Promise(r => resolves.push(r));
  };
}

I still welcome suggestions, but the new implementation answers my original question about how to implement this function without a dependency on EventEmitter (or something like it).

Chris
  • 945
  • 2
  • 12
  • 18
  • A few suggestions... There is no need to have multiple promises for each invocation in a "batch". Also, it doesn't re-apply existing `this` bindings to the debounced target function. I posted my solution as an additional answer. – Doug Coburn Aug 06 '20 at 15:28
32

In Chris's solution all calls will be resolved with delay between them, which is good, but sometimes we need resolve only last call.

In my implementation, only last call in interval will be resolved.

function debounce(f, interval) {
  let timer = null;

  return (...args) => {
    clearTimeout(timer);
    return new Promise((resolve) => {
      timer = setTimeout(
        () => resolve(f(...args)),
        interval,
      );
    });
  };
}

And the following typescript(>=4.5) implementation supports aborted features:

  1. Support aborting promise via reject(). If we don't abort it, it cannot execute finally function.
  2. Support custom reject abortValue.
    • If we catch error, we may need to determine if the error type is Aborted
/**
 *
 * @param f callback
 * @param wait milliseconds
 * @param abortValue if has abortValue, promise will reject it if
 * @returns Promise
 */
export function debouncePromise<T extends (...args: any[]) => any>(
  fn: T,
  wait: number,
  abortValue: any = undefined,
) {
  let cancel = () => { };
  // type Awaited<T> = T extends PromiseLike<infer U> ? U : T
  type ReturnT = Awaited<ReturnType<T>>;
  const wrapFunc = (...args: Parameters<T>): Promise<ReturnT> => {
    cancel();
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => resolve(fn(...args)), wait);
      cancel = () => {
        clearTimeout(timer);
        if (abortValue!==undefined) {
          reject(abortValue);
        }
      };
    });
  };
  return wrapFunc;
}

/**
// deno run src/utils/perf.ts
function add(a: number) {
  return Promise.resolve(a + 1);
}
const wrapFn= debouncePromise(add, 500, 'Aborted');

wrapFn(2).then(console.log).catch(console.log).finally(()=>console.log('final-clean')); // Aborted + final-clean
wrapFn(3).then(console.log).catch(console.log).finally(()=>console.log('final-clean')); // 4 + final_clean

Note:

  • I had done some memory benchmarks, huge number of pending promises won't cause memory leak. It seems that V8 engine GC will clean unused promises.
ahuigo
  • 2,929
  • 2
  • 25
  • 45
  • 4
    This makes sense: the client calling this repeatedly will each time handle the resolved value, and most often you don't want the same value handled multiple times, ... just the last one. – trincot Jul 10 '19 at 12:58
  • in this implementation, callbacks for skipped promises won't be ever called, only the final one will be called. In Chris's implementation, all of the attached callbacks will be invoked. So people should choose either one depends on their logic on the continuation of promises. – Serguzest Jul 30 '20 at 12:45
  • What will happen to all the waiting functions? Won't they be stuck forever? When typing this means that you will get it for each character typed. Memory wise you might have a problem. – Ben2307 Sep 17 '20 at 20:37
  • For anyone banging their head against the wall trying to get lodash debounce to work with promises and ended up here, this might be your answer: https://www.jordanpapaleo.com/Debouncing%20an%20API%20call%20with%20promise%20chaining/ – Lasf Oct 21 '21 at 23:00
  • See my solution below, which also clearing the memory by rejecting promise with try/catch block. – Ashish Rawat Apr 04 '22 at 11:25
  • @Ben2307 From my memory and cpu benchmark with chrome. I did not find any memory leak. It seems that v8's gc will clear all pending promises. – ahuigo Jun 13 '22 at 07:51
  • Having one function call for once text entry slowed was exactly what I wanted, and using this code almost always works. I use it with on('keyup', debounce(input)) But if I type really quickly then additional function calls seem to not clear in the chain. E.g. if I await for debounce to wait for text entry to finish and then do alert on the value, then almost the alert is only triggered once. if I bounce rapidly on a key then I consistently get it to alert multiple times, which is frustrating. – Tunneller Feb 24 '23 at 16:21
14

I landed here because I wanted to get the return value of the promise, but debounce in underscore.js was returning undefined instead. I ended up using lodash version with leading=true. It works for my case because I don't care if the execution is leading or trailing.

https://lodash.com/docs/4.17.4#debounce

_.debounce(somethingThatReturnsAPromise, 300, {
  leading: true,
  trailing: false
})
tanguy_k
  • 11,307
  • 6
  • 54
  • 58
user1736525
  • 1,119
  • 1
  • 10
  • 15
  • 4
    This works for me. With the default option (`{ leading: false, trailing: true }`), the debounced function will return `undefined` for the first call (and all the following calls unless there is an interval larger than `wait`). – Zhiyong Nov 12 '19 at 09:14
7

resolve one promise, cancel the others

Many implementations I've seen over-complicate the problem or have other hygiene issues. In this post we will write our own debounce. This implementation will -

  • have at most one promise pending at any given time (per debounced task)
  • stop memory leaks by properly cancelling pending promises
  • resolve only the latest promise
  • demonstrate proper behaviour with live code demos

We write debounce with its two parameters, the task to debounce, and the amount of milliseconds to delay, ms. We introduce a single local binding for its local state, t -

function debounce (task, ms) {
  let t = { promise: null, cancel: _ => void 0 }
  return async (...args) => {
    try {
      t.cancel()
      t = deferred()
      await t.promise
      await task(...args)
    }
    catch (_) { /* prevent memory leak */ }
  }
}

We depend on a reusable deferred function, which creates a new promise that resolves in ms milliseconds. It introduces two local bindings, the promise itself, an the ability to cancel it -

function deferred (ms) {
  let cancel, promise = new Promise((resolve, reject) => {
    cancel = reject
    setTimeout(resolve, ms)
  })
  return { promise, cancel }
}

click counter example

In this first example, we have a button that counts the user's clicks. The event listener is attached using debounce, so the counter is only incremented after a specified duration -

// debounce, deferred
function debounce (task, ms) { let t = { promise: null, cancel: _ => void 0 }; return async (...args) => { try { t.cancel(); t = deferred(ms); await t.promise; await task(...args); } catch (_) { console.log("cleaning up cancelled promise") } } }
function deferred (ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }

// dom references
const myform = document.forms.myform
const mycounter = myform.mycounter

// event handler
function clickCounter (event) {
  mycounter.value = Number(mycounter.value) + 1
}

// debounced listener
myform.myclicker.addEventListener("click", debounce(clickCounter, 1000))
<form id="myform">
<input name="myclicker" type="button" value="click" />
<output name="mycounter">0</output>
</form>

live query example, "autocomplete"

In this second example, we have a form with a text input. Our search query is attached using debounce -

// debounce, deferred
function debounce (task, ms) { let t = { promise: null, cancel: _ => void 0 }; return async (...args) => { try { t.cancel(); t = deferred(ms); await t.promise; await task(...args); } catch (_) { console.log("cleaning up cancelled promise") } } }
function deferred (ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }

// dom references
const myform = document.forms.myform
const myresult = myform.myresult

// event handler
function search (event) {
  myresult.value = `Searching for: ${event.target.value}`
}

// debounced listener
myform.myquery.addEventListener("keypress", debounce(search, 1000))
<form id="myform">
<input name="myquery" placeholder="Enter a query..." />
<output name="myresult"></output>
</form>
Mulan
  • 129,518
  • 31
  • 228
  • 259
  • `ms` is missing from `t = deferred()` in first code sample. Too small of a change to propose a change so leave it as a comment instead. – Err488 Oct 26 '21 at 23:14
  • What happens if the task function itself throws? – Johnny Jun 30 '23 at 19:24
4

Here's my version in typescript (mostly based on Chris one), if someone need it

function promiseDebounce (exec: (...args: any[]) => Promise<any>, interval: number): () => ReturnType<typeof exec> {
    let handle: number | undefined;
    let resolves: Array<(value?: unknown) => void> = [];

    return async (...args: unknown[]) => {
        clearTimeout(handle);
        handle = setTimeout(
            () => {
                const result = exec(...args);
                resolves.forEach(resolve => resolve(result));
                resolves = [];
            },
            interval
        );

        return new Promise(resolve => resolves.push(resolve));
    };
}
Herobrine
  • 1,661
  • 14
  • 12
  • I believe you should also add args to function type deffinition `(...args: any[]) => ReturnType` – korczas Oct 31 '20 at 11:24
  • Thanks for this! In my project, I renamed to `debouncePromiseToLastResult` to be more clear on how this differs from other solutions. – Kaspar Kallas Dec 01 '22 at 11:52
0

No clue what you are trying to accomplish as it vastly depends on what your needs are. Below is something somewhat generic though. Without a solid grasp of what is going on in the code below, you really might not want to use it though.

// Debounce state constructor
function debounce(f) {
  this._f = f;
  return this.run.bind(this)
}

// Debounce execution function
debounce.prototype.run = function() {
  console.log('before check');
  if (this._promise)
    return this._promise;
  console.log('after check');
  return this._promise = this._f(arguments).then(function(r) {
    console.log('clearing');
    delete this._promise; // remove deletion to prevent new execution (or remove after timeout?)
    return r;
  }.bind(this)).catch(function(r) {
    console.log('clearing after rejection');
    delete this._promise; // Remove deletion here for as needed as noted above
    return Promise.reject(r); // rethrow rejection
  })
}

// Some function which returns a promise needing debouncing
function test(str) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      console.log('test' + str);
      resolve();
    }, 1000);
  });
}

a = new debounce(test); // Create debounced version of function
console.log("p1: ", p1 = a(1));
console.log("p2: ", p2 = a(2));
console.log("p1 = p2", p1 === p2);
setTimeout(function() {
  console.log("p3: ", p3 = a(3));
  console.log("p1 = p3 ", p1 === p3, " - p2 = p3 ", p2 === p3);
}, 2100)

View the console when running the code above. I put a few messages to show a bit about what is going on. First some function which returns a promise is passed as an argument to new debounce(). This creates a debounced version of the function.

When you run the debounced function as the code above does (a(1), a(2), and a(3)) you will notice during processing it returns the same promise instead of starting a new one. Once the promise is complete it removes the old promise. In code above I wait for the timeout manually with setTimeout before running a(3).

You can clear the promise in other ways as well, like adding a reset or clear function on debounce.prototype to clear the promise at a different time. You could also set it to timeout. The tests in the console log should show p1 and p2 get the same promise (reference comparison "===" is true) and that p3 is different.

Håken Lid
  • 22,318
  • 9
  • 52
  • 67
Goblinlord
  • 3,290
  • 1
  • 20
  • 24
0

Here is what I came up with to solve this issue. All calls to the debounced function batched to the same invocation all return the same Promise that resolves to the result of the future invocation.

function makeFuture() {
  let resolve;
  let reject;
  let promise = new Promise((d, e) => {
    resolve = d;
    reject = e;
  });
  return [promise, resolve, reject];
}

function debounceAsync(asyncFunction, delayMs) {
  let timeout;
  let [promise, resolve, reject] = makeFuture();
  return function(...args) {
    clearTimeout(timeout);
    timeout = setTimeout(async () => {
      const [prevResolve, prevReject] = [resolve, reject];
      [promise, resolve, reject] = makeFuture();
      try {
        prevResolve(await asyncFunction.apply(this, args));
      } catch (error) {
        prevReject(error);
      }
    }, delayMs);
    return promise;
  }
}

const start = Date.now();
const dog = {
  sound: 'woof',
  bark() {
    const delay = Date.now() - start;
    console.log(`dog says ${this.sound} after ${delay} ms`);
    return delay;
  },
};
dog.bark = debounceAsync(dog.bark, 50);
Promise.all([dog.bark(), dog.bark()]).then(([delay1, delay2]) => {
  console.log(`Delay1: ${delay1}, Delay2: ${delay2}`);
});
Doug Coburn
  • 2,485
  • 27
  • 24
0

Both Chris and Николай Гордеев have good solutions. The first will resolve all of them. The problem is that they all be resolved, but usually you wouldn't want all of them to run.

The second solution solved that but created a new problem - now you will have multiple awaits. If it's a function that is called a lot (like search typing) you might have a memory issue. I fixed it by creating the following asyncDebounce that will resolve the last one and reject (and the awaiting call will get an exception that they can just catch).


const debounceWithRejection = (
  inner,
  ms = 0,
  reject = false,
  rejectionBuilder
) => {
  let timer = null;
  let resolves = [];

  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      const resolvesLocal = resolves;
      resolves = [];
      if (reject) {
        const resolve = resolvesLocal.pop();
        resolve.res(inner(...args));
        resolvesLocal.forEach((r, i) => {
          !!rejectionBuilder ? r.rej(rejectionBuilder(r.args)) : r.rej(r.args);
        });
      } else {
        resolvesLocal.forEach((r) => r.res(inner(...args)));
      }
      resolves = [];
    }, ms);
    return new Promise((res, rej) =>
      resolves.push({ res, rej, args: [...args] })
    );
  };
};

The rejection logic is optional, and so is the rejectionBuilder. It's an option to reject with specific builder so you will know to catch it.

You can see runing example.

Ben2307
  • 1,003
  • 3
  • 17
  • 31
-4

This may not what you want, but can provide you some clue:

/**
 * Call a function asynchronously, as soon as possible. Makes
 * use of HTML Promise to schedule the callback if available,
 * otherwise falling back to `setTimeout` (mainly for IE<11).
 * @type {(callback: function) => void}
 */
export const defer = typeof Promise=='function' ? 
    Promise.resolve().then.bind(Promise.resolve()) : setTimeout;
Bruce Lee
  • 4,177
  • 3
  • 28
  • 26