A Caching Strategy for Large Dynamic Option Lists

Sean Geoghegan
July 26, 2012

One of our clients recently had some performance problems with a page that displayed a very large form. This form had a tonne of select boxes with large lists of dynamically generated options. The options were based on some property of the user, e.g. their home state, and in most cases the list of options were completely different for each state. In total, the select boxes took about 500ms to render!

Typically, caching forms is a bad idea, because the content is specific to a single user. To do it in a way that doesn’t mess it up for all users, you would have to cache it on a per-user basis, i.e. something like this:

<%= cache("big_slow_select_for_#{}") do %> <%= render_big_slow_select(form, current_user) %> <% end %>

This is less than useful for a couple of reasons. Firstly, it’s going to cache it separately for each user, so your hit ratio is going to be pretty low.

Secondly, whenever the user changes that value the cache will have to be expired, which is going to make your hit ratio even worse.

Another option is to cache the html based on the selected value, like so:

<%= cache("big_slow_select_for_#{current_user.value_for_big_slow_select}") do %> <%= render_big_slow_select(form, current_user) %> <% end %>

This is better because at least users with the same selected value for the option will share the cache entries. For small lists of options this would probably work reasonably well, but if the option list is large you need to look at the probability of a cache hit. In our example, if there are 7 home states (we are Australian after all) and each home state will generate a different list of about 100 options and the selected values are evenly distributed across those options then the likelihood of a cache hit is something like 1:700, not that good.

But there is a better way. The slow part here, well in our case at least, was the generation of the options list. It was slow because a) it was large and b) it required querying and joining across a bunch of tables to build the list based on other options the user had set. So what we can do is cache that list of options based on the home state value of the user. So, for example, say the options list is based on the user’s state, we can have a helper method that builds and caches this options list, like so:

<code lang="ruby"> def build_big_slow_options_list(state) Rails.cache.fetch("big_slow_options_list_for_#{state}") do do_something_really_slow_that_builds_an_options_list_for(state) end end </code>

This will give use cache entries for each variation of the options list. Using the example of home state again, this gives us a cache entry for each home state, which translates to a 1 in 7 chance of getting a cache hit, much better than the 1 in 700 we were getting before. Now we just use the helper method in a standard Rails form building template:

<%=, build_big_slow_options_list(current_user.state)) %>

And the slow part, building the options list, will get cached. We get a cache hit for every user with the same state, which is going to be a pretty high hit ratio, and we never have to worry about stale caches of old user data since the user’s selection is not part of the cached value.

We used this mechanism on the client’s app and it reduced the load times for these forms by about 500ms. Win!