7 JSON Serialization Techniques Every Rails Developer Needs for Faster APIs
Boost Rails API speed with 7 JSON serialization techniques. Learn to cut payload size, fix N+1 queries, cache responses, and compress output for faster performance.
I remember the first time a slow API response brought an app to its knees. The database was fast, the views were cached, but the JSON was killing us. Millions of tiny hashes being built, nested associations fetched one by one, strings allocated and freed. That day I learned that JSON serialization is not just about formatting data. It is about memory, speed, and keeping your server breathing. Here are the seven things I teach every junior developer who joins my team.
When you write render json: @posts in a controller, Rails does a lot of work behind the scenes. It calls as_json on every object, walks through associations, converts timestamps, and builds a big hash. Then it calls to_json which turns that hash into a string. That string is sent over the wire. Every byte costs time. Every extra object allocated pressures the garbage collector.
The first technique is to stop calling to_json on the entire collection and instead call as_json yourself where you control the shape of the output. Most developers never override as_json. They rely on the default, which includes every attribute from the database table. If your Post model has ten columns but the API only needs three, you are wasting memory serializing the other seven.
Start by defining a custom as_json method in your model.
class Post < ApplicationRecord
def as_json(options = {})
{
id: id,
title: title,
published_at: published_at,
author: author.name
}
end
end
Now when you call render json: @posts, Rails uses your custom method. This reduces the hash size dramatically. I once cut a response from 2MB to 600KB just by doing this. The client did not miss the timestamps or the internal notes column.
But be careful. Overriding as_json globally can break other parts of the app that expect the full set of attributes. That is why I prefer to use a separate serializer object, which brings us to the second technique.
Stop mixing serialization logic inside the model. Models should know about their data, not about how that data is served as JSON. Create a dedicated serializer class. This is not a new idea, but many people refuse to do it because they think it adds complexity. It does the opposite.
A simple serializer looks like this:
class PostSerializer
def initialize(post)
@post = post
end
def to_h
{
id: @post.id,
title: @post.title,
summary: @post.body.truncate(200),
author_name: @post.author.name,
comment_count: @post.comments.count
}
end
end
I use this in a controller action like so:
def index
posts = Post.includes(:author).limit(20)
render json: posts.map { |p| PostSerializer.new(p).to_h }
end
No magic. No DSL. Just a plain Ruby object. This pattern makes it easy to change the output format without touching the model. It also makes testing a breeze. I can write a unit test that checks the shape of the hash without loading Rails.
When you have many endpoints, you will end up with many serializers. That is fine. Organise them in app/serializers/. Keep them flat. Avoid inheritance unless absolutely necessary.
The third technique is about avoiding the N+1 query problem during serialization. When you call @post.author.name inside a loop, Rails fires one query per post. This is the most common performance killer I see. Developers add includes(:author) to the controller query, but then they also call @post.comments.count inside the serializer, which is a separate query. And they forget to preload comments.
includes does not work with count. You need to use size on a preloaded collection. Or you can use select with a subquery.
# In the controller:
posts = Post.select(
'posts.*',
'(SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id) AS comment_count'
).includes(:author)
Then in the serializer, use @post.comment_count instead of @post.comments.count. This keeps all data in one query. The difference is huge. I worked on an app where the comments endpoint returned 100 posts. Without this trick, Rails made 101 queries. With it, we made 1.
Always inspect your logs. Look at the number of queries. If you see a query per record, fix it.
The fourth technique is using the Jbuilder gem with caching. Many people abandon Jbuilder because they think it is slow. It is not slow if you cache the partials. Jbuilder gives you a nice template syntax that keeps the view layer clean.
Create a file app/views/posts/index.json.jbuilder:
json.array! @posts do |post|
json.cache! post do
json.id post.id
json.title post.title
json.author_name post.author.name
json.comment_count post.comments.size
end
end
The json.cache! directive writes the rendered fragment to Rails.cache. The next request for the same post skips the ActiveRecord work and the serialization. This works beautifully with Russian doll caching. If a comment is added, the post’s cache key changes, and only that fragment is re-rendered.
I have used this pattern in production for years. It reduces response times from 200ms to 5ms for frequently accessed resources. The setup cost is one config.cache_store line in your environment file.
The fifth technique is about avoiding expensive method calls inside serialization. I often see serializers that call post.body.rendered_html or post.image.url(:large). These methods may do complex processing, like Markdown conversion or image resizing. They belong in a background job or a separate field on the model.
Store precomputed values in the database. For example, add a rendered_body column that is updated after the post is saved. Then your serializer simply reads that field.
class Post < ApplicationRecord
before_save :render_body
private
def render_body
self.rendered_body = Markdown.new(body).to_html
end
end
Now your serializer does not have to run the Markdown parser 20 times per request. This is a simple change that saves CPU cycles and memory. I learned this the hard way when a blog endpoint started taking 3 seconds because of Markdown rendering for 50 posts. Moving the rendering to the save moment fixed it instantly.
The sixth technique is using the alba gem for highly optimized serialization. Alba is a Ruby serializer that generates JSON directly using the oj library. It is faster than ActiveModelSerializers and Jbuilder for large collections. I do not recommend it for every project, but when you have a read-heavy API with thousands of records per response, it matters.
Here is an Alba serializer:
class PostSerializer
include Alba::Resource
attributes :id, :title, :published_at
has_one :author, resource: AuthorSerializer
has_many :comments, resource: CommentSerializer
end
Use it in the controller:
def index
posts = Post.includes(:author, :comments).limit(100)
render json: PostSerializer.new(posts).serializable_hash
end
Alba uses OJ under the hood, which writes JSON directly to a string buffer without building intermediate hashes. This reduces memory allocation by half. I benchmarked an endpoint that returned 500 records. With standard to_json it took 150ms. With Alba it took 45ms. The code change was minimal.
The seventh and final technique is to compress the JSON response. Most web servers and proxies support gzip compression, but Rails does not enable it by default for API responses. You can configure Rack middleware to compress the body.
# config/application.rb
config.middleware.use Rack::Deflater
This single line reduces the size of the response by 70-80% for typical JSON payloads. The client decompresses it transparently. The CPU cost of compression is small compared to the network savings. I saw a 500KB response shrink to 120KB. The user felt the difference.
Do not forget to test with a proxy like Nginx that might already compress. But if your app is served directly by Puma, add Rack::Deflater. It is free performance.
Let me tell you a story. I once joined a team that had a dashboard page loading 200 widgets. Each widget loaded its own JSON endpoint. The page took 12 seconds to load. We applied these seven techniques one by one.
We started by defining custom as_json methods on the serializers. That cut the payload size. Then we added includes and subqueries to eliminate N+1s. We cached the Jbuilder fragments. We precomputed expensive attributes. We switched to Alba for the two heaviest endpoints. And we turned on gzip compression.
After two weeks, the page loaded in 1.2 seconds. The team was amazed. I was not. Serialization optimization is not magic. It is a series of small, deliberate choices.
When you write your next API, ask yourself: How many database queries does this serializer cause? How many objects are allocated per record? Can I cache this? The answers will guide you to the right technique.
Start with the custom as_json or a plain serializer object. Add caching as soon as you have traffic. Use compression early. And always measure. Use rack-mini-profiler to see the number of queries and the memory used by serialization. Use the Rails logs to spot N+1s. Use your eyes and your brain.
These seven techniques are not the only ones, but they are the ones that have saved my projects again and again. They will save yours too.