I work on the backend team supporting an SNS with 170K users, built using Ruby on Rails.
We use Jbuilder to create API response payloads. However, we noticed that a significant portion of our response time was taken up by rendering partials. So, we decided to tackle this performance issue. In the end, we managed to reduce rendering time by 30%, and I'd like to share how we did it.
What is Jbuilder?
Jbuilder is a domain-specific language (DSL) integrated into Ruby on Rails for generating JSON responses. It allows you to intuitively build JSON responses as if you’re writing a view. However, performance issues can arise when handling large amounts of data.
Background of the Issue
In our application, building a single response payload often involves calling over 20 to 30 partial files. Some partial files that process large data sets took as much as 500ms to render.
By using New Relic to analyze the time spent on each segment, we found that partial rendering occupied the largest part of our API response time, followed by SQL execution.
The distribution of response times over a timeline is shown below. ( To clearly illustrate the situation, I tried to hand-draw the diagrams. )
During peak times, SQL execution times increase, but the cost of rendering remains relatively stable, regardless of the time of day.
Our team has been discussing query index optimization for several years. Due to the complexity of our data structure, we see it as a long-term challenge. This time, we chose to focus on rendering, which we could address in the short to medium term.
Identifying the Bottleneck
When investigating the partial!
method used for rendering partial files, I found many questions about its performance on GitHub issues and discussions.
Looking at the implementation in Jbuilder repository, I discovered that within the partial!
method, the render
method of the ActionView context
is called, aside from the separate template file lookup process.
# https://github.com/rails/jbuilder/blob/main/lib/jbuilder/jbuilder_template.rb
def _render_partial(options)
options[:locals].merge! json: self
@context.render options
end
It seems that the overhead of the render
method and the cost of file lookup are key factors causing the slowdown in rendering performance.
As an experiment, I measured rendering times by moving the operations from partial files directly into the caller, without using the partial!
method.
I used the rack-mini-profiler gem to measure performance in our development environment. I prepared hundreds of thousands of data entries in the development environment and gathered samples from 10 requests.
First, the existing implementation:
Next, the implementation with operations moved to the caller:
Remarkably, the average rendering time was reduced from 289.02ms to 166.22ms, a 42.5% decrease. I also confirmed that rendering nearly empty partial files 100 times only resulted in a delay of a few milliseconds.
At this point, it became clear that the partial!
method could be a bottleneck with large data volumes due to the render
method. However, given that our application manages hundreds of APIs, reducing partial!
method calls would make the code harder to maintain and is not a practical solution.
I was responsible for the entire task, from investigation to implementation, testing, and validation, as our backend team consisted of only three people, including myself. Since there wasn’t much internal knowledge about rendering, coordinating from scratch with the iOS and Android teams for response payload caching or redesign would take time and make it difficult to balance impact with effort.
Next, I investigated template engines. The first one I explored was a gem called jb, maintained by Ruby/Rails committer Matsuda-san. I replaced Jbuilder with it but didn’t see significant improvements.
Other candidates included the following gems such as alba, active_model_serializers, and jsonapi-serializer. Considering team-wide adoption and maintenance, we preferred gems with syntax similar to Jbuilder if possible.
By chance, while browsing a site called daily.dev, I searched for Jbuilder alternatives and found an article about a gem called props_template. This gem will be the focus today.
About props_template
props_template is a library that allows lightweight and fast JSON generation, with syntax similar to Jbuilder. It's developed by thoughtbot, which is familiar with factory_bot.
You can easily convert existing Jbuilder code, making migration straightforward with search and replace on your editor. Switching from it involves minimal review work, offering a low barrier to adoption. Since it can coexist with Jbuilder, you can use props_template for just part of your project.
Partial rendering in props_template can be done in two main ways: with a key and without a key. The array!
method differs from Jbuilder's partial!
method, featuring optimized logic in props_template that could reduce rendering times.
Partial rendering with a key
json.posts do
json.array! @posts, partial: ["posts/post"] do
end
end
or
Partial rendering without a key
json.partial! partial: "posts", locals: {posts: @posts} do
end
In props_template, the partial!
method also calls the render method on the context, similar to Jbuilder. Considering the potential cost, we chose not to use it.
# https://github.com/thoughtbot/props_template/blob/main/lib/props_template/base.rb
def partial!(**options)
@context.render options
end
Now, let's replace the Jbuilder implementation with props_template and check the benchmarks in the development environment. The entire process, including refactoring, took about only two days.
The existing implementation with Jbuilder:
(The same one we saw on the Identifying the Bottleneck section)
After replacing it with props_template:
The sample size was the same as before. The average rendering time was reduced from 289.02ms to 151.34ms, a 47.6% decrease.
Because the syntax is similar to Jbuilder, reaching an agreement within the backend team was very smooth. Our PM also said, "Let's prioritize and try it out," so we began with replacing one of the APIs.
Implementation Process
Schema Testing
Before replacing Jbuilder, we introduced schema testing using the committee-rails gem to ensure that the JSON schema generated by props_template matched exactly with Jbuilder.
Our API specifications were managed with Open API, so we integrated it into committee-rails to conduct schema tests in our request specs. Since the Open API documentation formed the basis of our tests, we took time to review the payload schemas thoroughly.
Converting Templates
I began converting the Jbuilder to props_template. We decided to manage prop files within a new directory named props_template
.
app/
└── views/
├── api/
│ └── v1/
│ ├── users/
│ │ ├── index.json.jbuilder
│ │ ├── show.json.jbuilder
│ └── props_template/ # New directory
│ └── users/
│ ├── index.json.props
│ └── show.json.props
├── layouts/
└── props_template/ # New directory
└── api.json.props
└── api.json.jbuilder
In the controller, we specified templates using the render
method.
The use_props_template?
method is mainly for the testing environment. For example, if you specify 'Jbuilder'
in params[:template_engine]
, you can test Jbuilder templates in the test environment.
Testing both templates allows for seamless switching to Jbuilder during validation. You simply change the default
from true
to false
.
class Api::V3::UsersController < Api::ApplicationController
def index
# do something
if use_props_template?(default: true)
render 'api/v1/props_template/users/index', layout: 'layouts/props_template/api.json'
return
end
end
private
def use_props_template?(default: true)
return default unless Rails.env.test?
params[:template_engine] == 'props_template'
end
end
Observations in the Production Environment
We used New Relic to gather data in the production environment. From the graph, you can see that starting from the release date, overall volatility decreased and response times improved. The vertical axis shows response time, and the horizontal axis shows dates.
Examining performance a week before and after, rendering time improved by about 30%.
Rendering time | Average |
---|---|
Before | 775.0466 ms |
After | 544.2676 ms |
For response time, which the backend team primarily references as the 95th percentile response time, there was a reduction from 2.11s to 1.57s, marking an improvement of about 26%.
Response time | Average | Median | 95th percentile | 99th percentile |
---|---|---|---|---|
Before | 1.41s | 1.32s | 2.11s | 2.67s |
After | 1.15s | 1.1s | 1.57s | 1.99s |
Conclusion
The API we tested was one of the heavier ones, so there were many areas for improvement, but seeing such significant progress in less than a month was encouraging.
While examples of improving partial rendering with props_template are still limited, we’ve gathered positive internal insights over the past month and plan to apply similar improvements to other APIs.
Additionally, there are still numerous layers for improvement, such as data structure, SQL indexing, caching, and removing unnecessary fields, which we did not address this time. We aim to continue enhancing these areas in the future.
That is about it. Happy coding!
Top comments (0)