Greener Pastures: Migrating a Production API from ActiveModel::Serializers to Fast JSON API
Here at Soundstripe, we use JSON API as our REST API specification, and we like it a lot. It prevents a lot of the bike shedding that’s common when architecting and iterating an API.
If you’ve ever built a Rails API, or JSON endpoints in a Rails application, you’ve experienced the serialization bottleneck that Rails has long suffered from, partially due to ActiveModel::Serializers. This is particularly evident the larger your responses get.
That’s not to dog on AM:S; it served us well for a year or so, but we were wanting more out of our API. AM:S is a large library supporting multiple specs, not just JSON API, so it’s a jack-of-all, master-of-none situation.
Performance is critical to us, because we want our users to be able to find the best music as quickly as possible. We set out to find a solution.
There are a couple other great gem choices which have popped up recently, which can format responses following the JSON API spec, and seem to have better performance than AM:S, such as jsonapi-rb. However, it would have been a large undertaking for us to swap serialization DSLs, especially if we weren’t 100% sure we’d see any major performance or development efficiency benefit from switching.
Patience paid off, because a few months ago the stars aligned when Netflix’s awesome tech team released fast_jsonapi. With performance specs built into the library to ensure quicker response times than AM:S (up to 25x faster in some cases), and a DSL aiming to replicate AM:S’ as close as possible, it seemed like a no-brainer.
This writeup offers advice tailored towards moving from AM:S to FAST JSON API in a production Rails application, benchmarks using actual data, caveats, and how to get the most out of Netflix’s new gem.
Setup
Fast JSON API has pretty solid setup instructions in its repo’s README, so follow those to get it up and running. There are a few additional things you’ll need when migrating from AM:S, and without prior in-depth knowledge of Rails’ parameter parsing and what AM:S is doing under the hood when installed, it can be cryptic figuring out why all of your POST requests are suddenly showing blank parameters on the server after you rip it out. For a smooth transition:
- Required — You need to register the proper MIME type so Rails knows how to handle JSON API requests.
- Required — You need to set up a parameter parser to digest parameters.
- Optional — You can add a renderer so your controller can properly set the response
Content-Type
header. This is also useful if you are needing to honor traditional JSON, or non-JSON-API-spec requests, as well as JSON API requests in your API. Or, if you’re like us, you might just want clear semantics :)
We chose to implement all of these in config/initializers/mime_types.rb
:
This code was pulled from AM:S here.
JSONAPI_MEDIA_TYPE
is a constant we defined in our config/initializers/constants.rb
file, where we keep global constants. You can define it wherever you like as long as this file has access to it (you can even uncomment the top line of the Gist above for it to just work), or you can swap the constant out with the string itself ('application/vnd.api+json'
).
Implementation & Migration
serialization_scope
This concept doesn’t exist in Fast JSON API, so whatever you were globally passing in as your serialization_scope
before, will need to be passed as params to each individual serializer. The way we solved this was creating a render_jsonapi
method that calls our renderer we created before (render jsonapi: @whatever)
, passes our defaults, and allows our controllers to override certain options through arguments. Here’s our RenderJsonapi
concern, adjusted to be a bit more generic for others to use:
Using it looks like this:
type
At the time of writing, record types default to being singularized in Fast JSON API, so if your AM:S implementation made your Shoe
model’s type display as “shoes” by default, you’ll need to adjust your serializer accordingly using the set_type
method:
Also, if you have a defined relationship, the relationship’s serializer won’t use its declared type, it will use the default. So even if your UserSerializer
has set_type :users
, you’ll still need to manually specify its type where its declared as a relationship. You can do this by using the record_type
option, like so:
Conditional Attributes & Relationships
Two features we helped contribute to the library were conditional attributes and conditional relationships. They rely on a Proc
instead of a custom method, so you may need to adjust your existing conditional attribute logic to work with that flow. For reusable Proc
s, we save them as a constant (see SIGNED_IN
below):
You can also chain Proc
s together if needed. For instance:
Performance
We still need to do production benchmarks, particularly with load testing, but we’ve seen noticeable improvements, and there’s more to come. One thing that’s notable, is that with any serialization library (even home-rolled), any additional computation that is done at serialization time will slow down response time. It’s no secret that Ruby isn’t known for its performance, so the less code, the better :)
Using either library, our production application (including latency and other factors) usually winds up being from a few hundred milliseconds to twice as slow, depending on request initiation location, size of the request, and current load on our servers.
With nearly identical implementations of both libraries, identical databases, serializing the same records, our locally ran (not production, consider these to be twice as long in production) benchmarks on one of our heaviest endpoints showed the following results:
Records with associations
10–12 associations of the same type per record, so 10 records really means 100 or more serialized objects in this case. The associations also have LOTS of execution-time computations as well (string concatenation, nested hash construction, etc.), so they’re heavy serializers.
AM:S
Records | Time (rough average)
10 ~625ms
100 ~5.0sFast JSON API
Records | Time (rough average)
10 ~500ms **20% Improvement**
100 ~4.5s **10% Improvement**
Records without associations
10 records means 10 serialized objects in this case. There are very few runtime computations on these main records, so they’re pretty light: about 2kb per record.
AM:S
Records | Time (rough average)
10 ~175ms
100 ~1.2s
1000 ~1.2sFast JSON API
Records | Time (rough average)
10 ~120ms **30% Improvement**
100 ~160ms **85% Improvement**
1000 ~160ms **85% Improvement**
Takeaways
For maximum performance, limit execution-time computations (most important) and don’t serialize associations. This can be quite an undertaking. We still haven’t finished optimizing our implementation (the 20% improvement has launched, but not the 85% improvement that you see above). Basically, the less your API has to compute at serialization time, the more you’ll benefit from Fast JSON API:
- If constructing hashes, string or arrays at execution-time, cache those in the database or use a Rails model cache.
- If you need association IDs, but not association data, just cache the IDs on the record to prevent extra DB queries.
Also, Fast JSON API is still missing some features that AM:S has. For instance, if records are not “included” in AM:S, you can prevent the resource type and ID from being rendered in the relationships hash, avoiding costly database queries for associations. This is not currently possible in Fast JSON API without using conditional relationships to prevent the relationships from being rendered entirely. However, new features are being added constantly (as mentioned before, we’ve contributed a couple here and here, which are in the gem as of version 1.3!), so I’m sure it won’t be long before it’s almost 1:1 with AM:S in terms of features.
Lastly, if you’re using pagination, there’s no reason you can’t make your page size pretty huge (1000 records or more), unless your front-end needs smaller pages, because as our performance tests indicate, number of records hardly affects response time unless you’re including associations or doing heavy computations at serialization time. It’s worth noting that in that case, memory usage should be considered. Large groups of records being instantiated can be a bottleneck on its own (and is one of the reasons Fast JSON API is faster than AM:S, it doesn’t instantiate as many serializer instances when executing).
If you’re on the fence about rebuilding your current AM:S implementation using Fast JSON API, I hope this article, our performance comparisons, and the caveats/considerations we’ve mentioned help you make your decision!
As for us: we’re sold.
P.S. If you need awesome music for marketing videos, a podcast, etc. — check out our library at soundstripe.com!