Post Featured Image

TIL Thursday: Improve the Performance of Repeated GET Requests with Cached Models and Collections

This blog post is applicable to SuiteCommerce and all versions of SuiteCommerce Advanced.

You should be familiar with a number of classes we make available in the BackboneExtras folder; they are additions to Backbone that we, NetSuite, have made. For example, we've made customizations to the standard Backbone view to add new methods (eg to add modals easily) or to customize built-in ones (eg an option to showContent() to prevent scrolling). However, there are two extras in particular that you may have seen us use sparingly around the source code:

  • Backbone.CachedCollection
  • Backbone.CachedModel

If you do a search for where we use Backbone.CachedCollection and Backbone.CachedModel, you'll see an interesting list of places: facets, items, transactions, invoices, site search, locations, and so on. What do all of these places have in common? Long-living, read-only data from NetSuite.

Don't forget, collections and models are designed to be interactive: when a user enters data into a form, it is pushed into a frontend model and then synced up to the server's backend model. However, there are numerous occasions where we might make an initial call and then never allow users to modify the data and send it back to NetSuite.

For example, if we allow a shopper to use the store locator functionality to search for stores near to them, then we might cache the data about each individual store because it's almost certainly not going to change minute-to-minute (how often does a store change its address or phone number?). Similarly, if a customer looks up their transaction history, we expect it to be the same until, at least, they navigate away from the page and place another order.

So, any model or collection that uses these classes instead of the base versions, get a layer of caching. After the first fetch() is performed, its results are stored in the shopper's device's memory, and subsequent calls to the server are intercepted and short-circuited, and the existing results are returned instead.

For example, calls to the item search API are cached using a cached Backbone model so that identical calls are returned from memory, rather than subsequent calls to the server. This speeds things up for the shopper and lessens the load on the servers.

In the following gif, I've made an initial search for "dress" — this results in a GET to the server. I then modify my search to remove the last letter, triggering a search for "dres", which also results in a GET. Now, when I switch between the two, trying to do the searches again, the cached Backbone model simply pulls the results its already gotten from the server, and just serves those instead without making additional GET requests.

Let's take a look at how the cached model and collection work.

A cursory look at these two files doesn't reveal anything particularly mind-blowing — they're just extended versions of their base classes — but that's because their power lies in a third class: Backbone.CachedSync.

You'll note that the cached model and collection look similar to each other, and the crucial bit of code is that we extend the standard collection/model's sync() method with Backbone.CachedSync's cachedSync() method. Thus, whenever we call for data to be sent to or received from the server, this method will be called instead. So, what's actually happening in this new method?

How cachedSync() Works

To start with an aside, one of the surprising things about cachedSync() is that it has remained almost completely unchanged since our earliest versions. Conceptually and technically, there has not been a need to either improve or fix it. It's a SuiteCommerce stalwart.

Anyway, if you take a look into the file (I recommend doing so, because I'll be running through it in order), you'll see it create a new method for Backbone called cachedSync(). Backbone's sync() has come up before, briefly, in a few previous posts — it is essentially a command that manages the version of the data model and the one we keep on the server; cachedSync() will be used instead, when specifically invoked.

Create a Dummy Object

The first thing that happens, is that we catch whether the request made to it is for a read action (ie a GET) — if it's not, we just pass it on straight to the standard sync() method to handle using Backbone.sync.apply(this, arguments);. If it is, then we move on the main part!

We start by getting the request URL. This is going to be used like a unique identifier; ie, if the same exact URL is requested again, it will be matched against the previous call and its results will be served again, without contacting the server.

Then we prepare the AJAX call template. Remember, calls to the items API are asynchronous and so we must return a deferred object (promise). Crucially for us, we need to make sure that the first time results are requested, they are the ones the server sent us, and that all subsequent requests get routed to these results.

What we're effectively doing is creating a dummy AJAX object; a lookalike. So, we construct a deferred object (promise) using, and then add as properties, the success/error conditions of the AJAX call, and then delete them the original call. This ensures that when we resolve the promise, the only success/error callbacks called are the ones attached to our dummy object, and not the original AJAX call's.

Once we have that ready, we push it into a newly created localCache object we've attached to Backbone; we say to it: if it already exists, then just use what we have in the cache, otherwise run Backbone.sync() and store that in the cache. Remember, we're using the URL as its unique identifier.

Create the State Handlers (Resolve and Reject)

Following on from that, we then just attach the resolution handlers from the original deferred object to our cached version. You'll see that we're using resolveWith(), rejectWith(), and notifyWith() — these are all methods attached to deferred objects in jQuery, that are similar to resolve(), reject(), and notify(). The key difference is the 'with' part, which allows us to provide additional context to the callbacks we want to run. Importantly, it means that we can resolve/reject/notify our cached object using the original deferred object as the context. Remember, this is all part of creating the dummy object.

An interesting part of the success callback is that we invoke evictRecords(). Defined at the top of the file, this is a simple cache size controller. We have arbitrarily set the limit to 100 objects, but this can be adjusted; it simply prevents scenarios where a very large number of calls are made in a single session, potentially filling up the browser device's memory. There's a neat little bit of code that says to get all of the keys of each property (ie its URL), and then push out the first (zeroth?) value if the cache is over the limit.

Once we have sorted out the handlers, we're essentially done for the object itself, and we just need to address the final two properties of the main object: addToCache() and isCached().

addToCache() and isCached()

So, here's the thing about these methods... they're not really used, depending on your code version. You see, in early versions of SCA (pre-Elbrus), we briefly used addToCache() and various other ways to cache certain results. For example, in the Vinson version of Receipt.Model, we added support for caching by adding the following code:

Model.prototype.sync = BackboneCachedModel.prototype.sync;
Model.prototype.addToCache = BackboneCachedModel.prototype.addToCache;

We did this because we were still using a now-outdated model for transactions called Order.Model. This model extended a standard Backbone model, so if you wanted it to be cached, you had to extended the (already) extended version to add in support. This changed in Elbrus, when we adopted the now-standard Transaction.Model class.

However, the new transaction model does not extend cached model class either; instead, we now we have a property called cacheSupport (which is defaulted to false).

In Transaction.Model's initialize() method is some code that checks this value and, if it's set to true, goes through each property in Backbone.CachedSync and copies them over to the transaction model:

if (this.cacheSupport)
{
  var self = this;

  _.each(BackboneCachedSync, function (fn, name)
  {
    self[name === 'cachedSync' ? 'sync' : name] = fn;
  });
}

If the property is called cachedSync, then we replace the sync property with it; otherwise they're just copied over using their existing names.

What this means is that in specific scenarios where we are happy to use caching (for example, when a shopper is looking up a receipt for a previous order), we can toggle it on by passing in {cacheSupport: true} when we extend it in our specific case (eg see Receipt.Model), otherwise we leave to the default (which is false) or explicitly set it to false (just in case).

return TransactionModel.extend({
  urlRoot: 'services/Receipt.Service.ss'
, cacheSupport: true
});

However, there is no record of addToCache() or isCached() actually being used in the code in earnest and you can safely ignore them.

So How Do I Use Cached Models and Cached Collections?

Well, the obvious answer is that instead of extending the base Backbone model or collection, you extend our cached versions instead. The reason we have the above code in the transaction model is because sometimes we want the results to be cached, and sometimes we don't, so we added in an extra option to account for these scenarios.

In complicated scenarios, you could implement something like what we have for the transaction model, or you can make use of the cacheSupport property if you're extending the transaction model itself, but in most cases you'll likely just need to extend Backbone.CachedModel or Backbone.CachedCollection instead of the base one... and that's it.

Remember, the cached versions are only for GET requests, and are only for data which is not going to change (or is very unlikely to). The cache lasts for however long the browser's current version of the Backbone object lasts. So, for example, the following things will empty the cache:

  • Switching applications (eg moving from shopping to checkout)
  • Refresh the page in the browser
  • Navigating away from the site and then returning, even if its the same tab

Thus, unless your shoppers keep your site open in a tab and never move away from the current application, then they will get cached results, but simply refreshing the page will solve that. Also, don't forget that the default limit on cached calls is 100, with the oldest being pushed out first.

Also, remember that you can access the contents of this cache by using Backbone.localCache in your browser's developer console.

Finally, you might be thinking that the results of calls are often cached on the servers or on CDNs — so why bother with this? Well, you are correct, we do cache a lot of things on our CDNs but one of the prime benefits of doing this is that it short-circuits the call from happening in the first place. While our caches are speedy, there's nothing more speedy than accessing it straight from the device's memory. By avoiding repeated calls, we lighten the loads ever so slightly on the servers, and we speed things up for our customers. It's win-win!