Search code examples
javascriptnode.jspromisememoization

node.js, return the same promise for all callers until a promise is resolved


I have a heavy promise function that gets called multiple times with the same parameters. I want it to avoid re-running the same calculations for the same params. Instead, if a consecutive call has the same params, return the promise created by the previous one.

Normally, for such cases, I would use memoizee package (or memoize in lodash). However, this promise returns a large dataset, which can be problematic to keep in memory. Since these packages cache the results in-memory, they won't be a good fit for this case.

An simplify example:

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

async function sleepOneSec() {
  console.log("going to sleep");
  await sleep(1000)
};

async function myApp() {
  await Promise.all([1, 2, 3].map(async item => {
    console.log('item', item);
    await sleepOneSec();
  }));
  console.log('promise.all is done');
  await sleepOneSec();
  console.log('completed');
}

myApp().then(() => {}).catch(err => console.log(err));

currently, without any memoization, the output is:

item 1
going to sleep
item 2
going to sleep
item 3
going to sleep
promise.all is done
going to sleep
completed

Ideally I want the results to be:

item 1
going to sleep
item 2
item 3
promise.all is done
going to sleep
completed

Solution

  • OK, here's an implementation for sleepOneSec(num) that generates the output you asked for. It creates a cache for the promise from sleepOneSec() that is indexed by the arguments. The promise is removed from the cache as soon as it resolves or rejects so it is only reused while "in progress":

    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
    
    const sleepCache = new Map();
    
    async function sleepOneSec(num) {
        // serialize args
        const args = JSON.stringify({ num });
        let p = sleepCache.get(args);
        if (!p) {
            console.log("going to sleep");
            p = sleep(num);
            sleepCache.set(args, p);
    
            // when this promise resolves, remove it from the cache
            // so it won't be used any more
            const remove = () => sleepCache.delete(args);
            p.then(remove, remove);
        }
        return p;
    }
    
    async function myApp() {
        await Promise.all([1, 2, 3].map(async item => {
            console.log('item', item);
            await sleepOneSec(1000);
        }));
        console.log('promise.all is done');
        await sleepOneSec(1000);
        console.log('completed');
    }
    
    myApp().then(() => {}).catch(err => console.log(err));

    If the arguments to the function in question contain objects, then you have to make sure they are uniquely serializable with JSON.stringify() or you need to manually convert them into something that is representative of their uniqueness that is serializable by JSON.stringify(). It's also important to manually handle serialization of any objects because JSON.stringify() does not necessarily stringify properties in the same order which would break the comparison. This is why I asked about the arguments in your real code. All you offered me when I asked about what the real arguments are was a single numeric argument so that's what is shown here. But, if you have objects as arguments, those have to be handled carefully so the caching works properly.