Fast JSON APIs in Rails with Key-Based Caches and ActiveModel::Serializers

Want to make your Rails JSONAPIs fast? Blisteringly fast? In a project I’ve been working on recently, I reduced requests from 5 seconds (or more!) to at most 0.5 seconds by using ActiveModel::Serializers and partial object caching with object composition. Before I explain how, I’ll need to explain why.

In this example, the Location model is using the geocoder gem to automatically geocode the model’s address. To find all locations within 15 miles of Boston:

Location.near('Boston', 15)

This returns an ActiveRecord::Relation with each location decorated with two pieces of additional data: distance and bearing. This data is dependent upon the point of interest searched; if I were to change my query from Boston to Cambridge (just across the Charles River), the distances and bearings would be affected even though the location data from the database would be the same.

This allows serializers to use Rails.cache behind the scenes to cache generated JSON. However, enabling caching on the LocationSerializer doesn’t make sense because distance changes based on the search term. Finding locations within 15 miles of Boston would cache each relative to that search string; searching in Cambridge would return the previously cached results from Boston meaning most distances would be invalid.

The solution is figuring out how to cache some of the location JSON generated but not all of it (omitting distance, of course). Object composition is the perfect solution; by writing a brand new serializer that combines two other serializers (one which caches the location data from the database and one which adds distance), we can rely on the cache for the heavy lifting of the JSON and the decoration of distance for every search:

This composite serializer merges our existing serializer with a new DistanceSerializer. ActiveModel::Serializer#serializable_hash is the method which contains all the logic for #to_json so overriding #serializable_hash on the SearchSerializer will impact #to_json correctly.

By enabling caching the other related pieces of data, we’re effectively enabling key-based cache expiration due to ActiveModel::Serializers‘s behavior under the covers. For static data, this is incredibly effective because there will rarely be a cache miss after the cache is warmed.

While caching location data helps recurring searches (dependent on locations displayed and not by the search point of interest), it doesn’t improve responses for searches where the locations haven’t been cached. Depending on the dataset, warming the cache may be the best option. While this makes plenty of assumptions about usage data, it worked well for the app I worked on:

Want to level up your testing game?
Learn about testing Rails applications and TDD
in our new book
Testing Rails.
The book covers each type of test in depth,
intermediate testing concepts,
and anti-patterns that trip up even intermediate developers.