Idea: Caching Meteor method results on client

So I had an idea: I have a quite expensive method call which is called quite frequently due to (React) UI changes, but 99.99% of the time returns the same result for the same parameters.

So I hacked together my own little caching function (without any invalidation yet), which actually works beautifully. It uses localForage to store the method results on the clients.

Meteor.callCached = function() {
	const originalCb = typeof arguments[arguments.length-1] == 'function' ? arguments[arguments.length-1] : null;
	if(!originalCb)	{
		return Meteor.call.apply(null, arguments);
	}	
	const localKey = Array.from(arguments).map(arg => {
		if(typeof arg == 'function') return null;
		if(Array.isArray(arg)) return arg.map(val => String(val).length < 10 ? String(val) : String(val).substr(10,999999)).join('-');
		if(typeof arg == 'object') return Object.keys(arg).map(key => {let val = String(arg[key]); val.length < 10 ? val : val.substr(10,999999); return `${key}-${val}`}).join('-');
		return arg;
	}).filter(a => !!a).join('-');

	return localforage.getItem(localKey, (err, value) => {
		if(err) console.error(err);
		if(!err && value && originalCb) return originalCb(err, value);
		if(err || !value) {
			arguments[arguments.length-1] = (err, resp) => {
				if(err) console.error(err);
				else localforage.setItem(localKey, resp, err => {
					if(err) console.error(err);
				});
				return originalCb(err, resp);
			}
			return Meteor.call.apply(null, arguments);
		}
	});
}

Please let me know what you think, if this is for some reason a bad idea, any improvement suggestions … if someone is interested I can make a small package out of it and release it on github.

Cheers, Patrick

4 Likes

It looks pretty nice - my one suggestion would be caching on the server, if many users frequently call this function then this caching will have a limited improvement when compared to caching on the server which will impact everyone. We use this pattern in our admin panel for some analytics that are quite heavy to compute - if two users hit at the same time (within 1 minute of each other) they share the same result, the next person to call after 1 minute gets the new result, and everyone who calls within the next minute shares that

Hmm that makes sense to also cache it on the server for the most common parameters. Some of the parameters really depend from user to user, so this doesn’t always make sense.
Thanks for your input! :smile:

I was just wondering if Meteor isn’t doing something like this already out of the box and I was missing something?! It baffles me that there is no caching at all in place by default?! Am I missing something? Is this somehow bad practice for a reason I can’t see yet?

It’s not bad practice - but its uncommon, calling your meteor methods too frequently could be considered poor reactivity in your UI - if you are happy to say “don’t ever rerun the same method call with the same arguments on a reactive data change” then you shouldn’t be making the call when the arguments don’t change.

Something like this will trigger a meteor call whenever any data passed into the template changes, when you only care about 3 parameters:

...
Tracker.autorun(() => {
const data = Template.currentData();
Meteor.call("myFunc", data.arg1, data.arg2, data.arg3, ...);
})

It is here you should really be caching the result, you could compare data.arg{1-3} against the arguments used when the method call was last ran, and if different you could re-run, otherwise skip. A nice way to do this is Tracker.guard it allows you to depend on a subset of a reactive data source:

...
Tracker.autorun(() => {
const data = Tracker.guard(() => {
  const data = Template.currentData();
  return {arg1: data.arg1, arg2: data.arg2, arg3: data.arg3};
});
Meteor.call("myFunc", data.arg1, data.arg2, data.arg3, ...);
})

it’s not built into tracker but here is the snippet:

Thanks a bunch for your insights.
The thing is: in my case the parameters do change during a typical user session. but if the parameters are the same, 99.9% of the time the call will return the same result.
In my case (React) I would compare my prevProps with this.props - but this does not help in this case, as the parameters definitely changed but still, I have to somehow cache the results if the same params already occured some time ago.

I’m not sure I follow - though I’m not really familiar with react, but if the parameters don’t change you should be able to not call the method, can you show your UI code that triggers the method?

I think I can explain:
My app is called Orderlion - with this app a restaurant owner can do his procurement over a single platform. Meaning, the restaurant f&b manager can order all his good from all his suppliers over Orderlion.

If the user chooses a supplier, in the sidebar, the (sometimes huge!) item categories tree is loaded via a Meteor.call() and displayed. So the this.props of the tree basically are the supplierId and some other stuff. As long as the user stays within this one supplier the tree in the sidebar isn’t even re-rendered, so all is fine.

now the user navigates to another supplier --> this.props (or supplierId for that matter) changed --> new method call --> new item categories tree.

If the user now navigates back to the first supplier the supplierId changed again, so only comparing the new supplierId to the old supplierId isn’t enough - I need to cache the exact item categories tree for the first supplierId and just return it from cache.

To keep all this as general as possible I decided to code my own Meteor.callCached function.

Ah, I understand now - you probably do have the right idea then, I don’t think you need localForage for this though, unless you care about caching across browser reloads? Also, how do you invalidate this cache (e.g., when will you call it again?)

Yes I want to cache also if there is some browser reload somewhere. :smile:

Invalidation: Not perfect I know, since this is now hardcoded for every call but better than nothing :slight_smile:

Meteor.callCached = function() {
	const originalCb = typeof arguments[arguments.length-1] == 'function' ? arguments[arguments.length-1] : null;
	if(!originalCb)	{
		return Meteor.call.apply(null, arguments);
	}	
	const localKey = Array.from(arguments).map(arg => {
		if(typeof arg == 'function') return null;
		if(Array.isArray(arg)) return arg.map(val => String(val).length < 10 ? String(val) : String(val).substr(10,999999)).join('-');
		if(typeof arg == 'object') return Object.keys(arg).map(key => {let val = String(arg[key]); val.length < 10 ? val : val.substr(10,999999); return `${key}-${val}`}).join('-');
		return arg;
	}).filter(a => !!a).join('-');

	return localforage.getItem(localKey, (err, value) => {
		if(err) console.error(err);
		if(!err && value && originalCb && value.data && value.ts && moment().diff(value.ts, 'minutes') < 60) {
			return originalCb(err, value.data);
		} else {
			arguments[arguments.length-1] = (err, resp) => {
				if(err) console.error(err);
				else localforage.setItem(localKey, { ts: new Date(), data: resp }, err => {
					if(err) console.error(err);
				});
				return originalCb(err, resp);
			}
			return Meteor.call.apply(null, arguments);
		}
	});
}

looks like that will do the trick!

I also use something similar to this but I actually do the caching on the server. I use a modified version of validated method that allows to pass a property called cacheTTL to each method and uses memory-cache package for caching the method response.

Has been working correctly for some time now.

2 Likes

That sounds awesome, is it a package?

Not yet, but im thinking about it. It fixes an issue with npm simple-schema validation and adds this cache feature. Also thinking about adding a redis option so that the cache can be shared between multiple servers.

Ill post it on on the forum when I publish it, soon.

3 Likes

This really sounds to me that you should be using a database subscription! Instead of calling a Method, you could simply subscribe to “supplierItems” passing the supplier ID. When the user navigates to another supplier, just do not stop the first subscription so all data will be kept on the minimongo… You could even leverage this with a “favorites” subscription, that could be grounded on the client so it would not even need to be loaded from the server (maybe without the price field, which would need re-actualisation) so that would load super fast, and even offline for that matter.

Although I see a great opportunity for your method caching mechanic, I do feel that your exact problem has a way easier solution already provided by Meteor.

1 Like

Yeah I agree with @Salketer, this is much easier with Minimongo and subscriptions

I use meteorhacks:subs-manager when I want to temporarily hold onto data so it’s there instantly when the user goes back

Have you thought about storing the cached tree in the database? In this way, you can cache across different servers and also invalidate the database object, when the data changes.

@klabauter, nice idea I was thinking the same for my app, but with some difference.

  • Why not use Session package which comes built in meteor instead of localForage
  • For invalidating cache, why not maintain the lastupdated field on the intended table for query, and check only this field and replace the cache if the lastupdated field on client and table are not same? In this approach ofcourse we first render screen with local cache and then after render we check for changes to be made in cache.
    And ofcourse I also use react so this can be handled in react in a smooth way, may be also with some animation if required.

@chidan
Thanks for your feedback!

I am not using the Session package, because Session is lost after a page reload. I want my cache to be persistent even after a page reload or after closing and reopening the browser. That’s why I am using the localforage package.

About the invalidation: I am not sure if I understand you correctly. Please feel free to adapt my code and maybe we want to release a small package on github?! Any plans on doing that any maybe get some contributors on board?

cheers, Patrick

Of course, I should have clarified, I use persistent session package - https://github.com/cult-of-coders/meteor-persistent-session.
For invalidation of cache, instead of invalidating the cache based on time, I want to maintain a last updated field on table and refresh client cache based on this.
For example lets consider a inventory stock table. I want to check when is the last updated date and if this is not same as my local cache, I want to replace cache in backend. Of course on initial render data will be wrong, but by cache will be refreshed in the background and we can always show a badge to the user, saying data is updated.

A package will be good, but just to mention that I am a novice coder, but will be happy to contribute.

The other future proof solution is to use Apollo Graphql - https://www.apollographql.com/docs/react/advanced/caching.html

1 Like