2

I have a web page where different parts of it all need the same back-end data. Each is isolated, so they each end up eventually making the same calls to the back-end.

What is the best way to avoid making a call to the web server when one is already in progress and initiated by a different piece of code on the same web page?

Here's an example. I'll use setTimeout to simulate an asynchronous call.

Let's assume there's an async function that returns the list of contacts, which is basically a simple array of strings in this example:

var getContacts = function() {
  log('Calling back-end to get contact list.');
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      log('New data received from back-end.');
      resolve(["Mary","Frank","Klaus"]);
    }, 3000);
  });
};

Now, let's create three separate functions that each call the above function for different purposes.

Dump out the list of contacts:

var dumpContacts = function() {
  getContacts().then(function(contacts) {
    for( var i = 0; i < contacts.length; i++ ) {
      log( "Contact " + (i + 1) + ": " + contacts[i] );
    }
  });
};

Determine if a particular contact is in the list:

var contactExists = function(contactName) {
  return getContacts().then(function(contacts) {
    return contacts.indexOf(contactName) >= 0 ? true : false;
  });
};

Get the name of the first contact:

var getFirstContact = function() {
  return getContacts().then(function(contacts) {
    if ( contacts.length > 0 ) {
      return contacts[0];
    }
  });
};

And here is some example code to use these three functions:

// Show all contacts
dumpContacts();

// Does contact 'Jane' exist?
contactExists("Jane").then(function(exists){
  log("Contact 'Jane' exist: " + exists);
});

getFirstContact().then(function(firstContact){
  log("first contact: " + firstContact);
});

The above routines make use of a global log() function. console.log() could be used instead. The above log() function log's to the browser window and is implemented as follows:

function log() {
  var args = Array.prototype.slice.call(arguments).join(", ");
  console.log(args);
  var output = document.getElementById('output');
  output.innerHTML += args + "<br/>";
}

and requires the following in the html:

<div id='output'><br/></div>

When the above code is run, you will see:

Calling back-end to get contact list.

and

New data received from back-end.

three times, which is unnecessary.

How can this be fixed?

This sample is on Plunker can be executed: http://plnkr.co/edit/6ysbNTf1lSf5b7L3sJxQ?p=preview

zumalifeguard
  • 8,648
  • 5
  • 43
  • 56
  • 1
    See also [Caching a promise object in AngularJS service](http://stackoverflow.com/q/18744830/1048572) – Bergi Jan 26 '15 at 03:16
  • 1
    You might look into how Flux handles this. Essentially, you create a "store". The store holds the data returned from a request (and whatever else you want to put in there). This is essentially an in-memory cache. To control when the data is refreshed, you use an updater. The updater is where you would build your request URI. That URI can be checked against the previously requested URI, and if it has not changed, no request is necessary, and the store accessor will simply return the data it already has in memory. – Shmiddty Apr 28 '15 at 20:22

3 Answers3

2

If the desire is to reduce the number of unnecessary calls to the back-end, then hang on to the promise and while it's still unresolved, return it it for new calls rather than issuing another call to the back-end.

Here's a routine that converts an async function, one that returns a promise, into one that's only called while the promise is still unresolved.

var makeThrottleFunction = function (asyncFunction) {
  var currentPromiser = getPromise = function() {
    var promise = new Promise(function(resolve, reject) {
      asyncFunction().then(function(value) {
        resolve(value);
        currentPromiser = getPromise;
      }).catch(function(e) {
        reject(e);
        currentPromiser = getPromise;
      });
    });

    currentPromiser = function() {
      return promise;
    };

    return promise;
  }

  return function () {
    return currentPromiser();
  };
};

In your routine, you can convert getContacts like so:

var getContacts = makeThrottleFunction(getContacts);

Or pass the entire body of the function directly.

Keep in mind that this will only work for parameterless calls to the back-end.

Example plunker code: http://plnkr.co/edit/4JTtHmFTZmiHugWNnlo9?p=preview

zumalifeguard
  • 8,648
  • 5
  • 43
  • 56
  • 1
    You have the general idea but please refactor the deferred anti-pattern out of this - the whole `new Promise` thing can be `var promise = asyncFunction()` – Benjamin Gruenbaum Jan 23 '15 at 13:06
  • This confuses me. There are too many closures / functions going on. – user2864740 Jan 25 '15 at 03:33
  • Benjamin, thank you. If I do that, I still need to assign: currentPromiser = getPromise; somewhere. Would it look like this? var promise = asyncFunction().then(function(value) { currentPromiser = getPromise; return value; }).catch(function(e) { currentPromiser = getPromise; return promise.reject(e); }); I'm not convinced about the promise.reject(e) part. – zumalifeguard Jan 25 '15 at 03:39
  • Why aren't you convinced about it? Why do you even need all that infrastructure can't you just return the promise itself and be over with it (and cache it in a variable outside the function?) Also, it doesn't really do any throttling. – Benjamin Gruenbaum Apr 28 '15 at 19:48
2

Just cache the result in the function making the call:

function cache(promiseReturningFn){
    var cachedVal = null;  // start without cached value
    function cached(){
        if(cachedVal) return cachedVal; // prefer cached result
        cachedVal = promiseReturningFn.apply(this, arguments); // delegate
        return cachedVal; // after we saved it, return it
    }
    cached.flush = function(){ cachedVal = undefined; };
    return cached;
}

This has the caveat of failing for actual results that are null but otherwise it gets the job done nicely.

You can now cache any promise returning function - the version above only caches ignoring arguments - but you can construct a similar one that has a Map and caches based on different arguments too - but let's focus on your use case.

var getContactsCached = cache(getContacts);

getContactsCached();
getContactsCached();
getContactsCached(); // only one async call ever made

The cache method is actually not even related to promises - all it does is take a function and cache its result - you can use it for anything. In fact if you're using a library like underscore you can use _.memoize to do it for you already.

Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
-4

Edit, Updated

Removed "nested" ternary pattern; added

  • a) dfd.err(), .catch() to handle Promise.reject(/* reason ? */) arguments passed to dfd.fn();
  • b) args === "" within dfd.process() to handle "": empty String passed as argument to dfd.fn()
  • c) substituted "chaining" .then() calls for then.apply(dfd.promise, [contactExists, getFirstContact])

Native Error() passed as argument:dfd.fn(new Error("error")) handled at global scope; dfd.fn() still returns dfd.promise. Could possibly adjust before or at dfd.process(), to return "early" at Error or pass Error to dfd.err() ; depending on requirement. Not addressed at js below.

Try

var dfd = {
  // set `active` : `false`
  "active": false,
  // set `promise`: `undefined`
  "promise": void 0,
  // process `arguments`, if any, passed to `dfd.fn`
  "process": function process(args) {
    // return `Function` call, `arguments`, 
    // or "current" `dfd.promise`;
    // were `args`:`arguments` passed ?
    // handle `""` empty `String` passed as `args`
    return args === "" || !!args
             // if `args`:`Function`, call `args` with `this`:`dfd`,
             // or, set `args` as `value`, `reason`
             // of "next" `dfd.promise`
             // return "next" `dfd.promise` 
           ? args instanceof Function && args.call(this) || args 
             // set `dfd.active`:`false`
             // when "current" `dfd.promise`:`Promise` `fulfilled`,
             // return "current" `dfd.promise`
           : this.active = true && this.promise
  },
  // handle `fulfilled` `Promise.reject(/* `reason` ? */)`,
  // passed as `args` to `dfd.fn`
  "err": function err(e) {
    // notify , log `reason`:`Promise.reject(/* `reason` ? */)`, if any,
    // or, log `undefined` , if no `reason` passed: `Promise.reject()` 
    console.log("rejected `Promise` reason:", e || void 0);
  },
  // do stuff
  "fn": function fn(args /* , do other stuff */) {
    // set `_dfd` : `this` : `dfd` object
    var _dfd = this;
    // if "current" `dfd.promise`:`Promise` processing,
    // wait for `fulfilled` `dfd.promise`;
    // return `dfd.promise`
    _dfd.promise = !_dfd.active
                     // set, reset `dfd.promise`
                     // process call to `dfd.async`;
                     // `args`:`arguments` passed to `dfd.fn` ?,
                     // if `args` passed, are `args` `function` ?,
                     // if `args` `function`, call `args` with
                     // `this`:`dfd`; 
                     // or, return `args`
                   ? _dfd.process(args)
                     // if `_dfd.active`, `_dfd.promise` defined,
                     // return "current" `_dfd.promise`
                   : _dfd.promise.then(function(deferred) {
                        // `deferred`:`_dfd.promise`
                        // do stuff with `deferred`,
                        // do other stuff,
                        // return "current", "next" `deferred`
                        return deferred
                      })
                      // handle `args`:`fulfilled`,
                      // `Promise.reject(/* `reason` ? */)`
                      .catch(_dfd.err);
    return Promise.resolve(_dfd.promise).then(function(data) {
        // `data`:`undefined`, `_dfd.promise`
        // set `_dfd.active`:`false`,
        // return `value` of "current", "next" `_dfd.promise`
        _dfd.active = false;
        return data
      })
      // handle `fulfilled` `Promise.reject(/* `reason` ? */), 
      // if reaches here ?
      .catch(_dfd.err)
  }
};

    function log() {
        var args = Array.prototype.slice.call(arguments).join(", ");
        console.log(args);
        var output = document.getElementById('output');
        output.innerHTML += args + "<br/>";
    };

    var dumpContacts = function () {
        log('Calling back-end to get contact list.');
        return new Promise(function (resolve, reject) {
            setTimeout(function () {
                log('New data received from back-end.');
                resolve(["Mary", "Frank", "Klaus"]);
            }, 3000);
        });
    };

    var getContacts = function () {
        return dfd.async().then(function (contacts) {
            for (var i = 0; i < contacts.length; i++) {
                log("Contact " + (i + 1) + ": " + contacts[i]);
            }
        });
    };

    var contactExists = function (contactName) {
        return dfd.async().then(function (contacts) {
            return contacts.indexOf(contactName) >= 0 ? true : false;
        });
    };

    var getFirstContact = function () {
        return dfd.async().then(function (contacts) {
            if (contacts.length > 0) {
                return contacts[0];
            }
        return contacts
    });
    };


    // Test:

// Show all contacts
dfd.async(dumpContacts)
.then(getContacts)
.then.apply(dfd.promise, [
  // Does contact 'Jane' exist?
  contactExists("Jane").then(function (exists) {
    log("Contact 'Jane' exist: " + exists);
  })
  , getFirstContact().then(function (firstContact) {
    log("first contact: " + firstContact);
  })
]);

function log() {
  var args = Array.prototype.slice.call(arguments).join(", ");
  console.log(args);
  var output = document.getElementById('output');
  output.innerHTML += args + "<br/>";
  return output
};

var dfd = {
  "active": false,
  "promise": void 0,
  "process": function process(args) {
    return args === "" || !!args
           ? args instanceof Function && args.call(this) || args 
           : this.active = true && this.promise
  },
  "err": function err(e) {
    console.log("rejected `Promise` reason:", e || void 0);
  },
  "fn": function fn(args) {
    var _dfd = this;
    _dfd.promise = !_dfd.active
                   ? _dfd.process(args)
                   : _dfd.promise.then(function(deferred) {
                       return deferred
                     })
                     .catch(_dfd.err);
    return Promise.resolve(_dfd.promise).then(function(data) {
        _dfd.active = false;
        return data
      })
      .catch(_dfd.err)
  }
};

var dumpContacts = function() {
  log('Calling back-end to get contact list.');
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      log('New data received from back-end.');
      resolve(["Mary", "Frank", "Klaus"]);
    }, 3000);
  });
};

var getContacts = function() {
  return dfd.fn().then(function(contacts) {
    for (var i = 0; i < contacts.length; i++) {
      log("Contact " + (i + 1) + ": " + contacts[i]);
    }
  });
};

var contactExists = function(contactName) {
  return dfd.fn().then(function(contacts) {
    return contacts.indexOf(contactName) >= 0 ? true : false;
  });
};

var getFirstContact = function() {
  return dfd.fn().then(function(contacts) {
    if (contacts.length > 0) {
      return contacts[0];
    }
    return contacts
  });
};


// Test:

// Show all contacts
dfd.fn(dumpContacts)
  .then(getContacts)
  .then(function() {
    // Does contact 'Jane' exist?
    return contactExists("Jane").then(function(exists) {
      log("Contact 'Jane' exist: " + exists);
    })
  })
  .then(function() {
    return getFirstContact().then(function(firstContact) {
      log("first contact: " + firstContact);
    })
  });
<body>
  Must use browser that supportes the Promises API, such as Chrome

  <div id='output'>
    <br/>
  </div>

  <hr>
</body>
guest271314
  • 1
  • 15
  • 104
  • 177
  • Well, I read your code and I honestly think it's pretty bad, first of all it's full of doing things like `return contacts.indexOf(contactName) >= 0 ? true : false;` (why the `? true : false` ternary?), second it's pretty unreadable (the 4 level nested ternary), third it does some really weird stuff with deferreds and I'm not sure I completely understand the logic. – Benjamin Gruenbaum Apr 28 '15 at 19:49
  • @BenjaminGruenbaum Don't believe did those portions , mainly the `dfd` object . Yes, four nested ternaries. "Unreadable" ? Compared to ? Link to _The_ standard of "readbility" ? How to do the chat box ? – guest271314 Apr 28 '15 at 19:53
  • What do you mean by "don't believe did those portions, mainly the dfd object"? – Benjamin Gruenbaum Apr 28 '15 at 19:56
  • 2
    Also, I hope I'm not coming off as aggressive - I really do appreciate it when people participate in the promise tag - I just really find your code confusing and I've read and written at least a thousand answers on this tag. – Benjamin Gruenbaum Apr 28 '15 at 19:59
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/76484/discussion-between-benjamin-gruenbaum-and-guest271314). – Benjamin Gruenbaum Apr 28 '15 at 19:59