4 Easy Ways to Speed Up Your Rails App
Some of the best ways to make your Rails app faster are dead-simple and quite often missed.
The tragic part is that many of these problems come up over and over again and can be fixed quickly and incrementally. And the benefits of each of these improvements can be significant.
This post focuses on four quick fixes you can apply to your Rails app that require relatively little effort and often deliver a huge payoff.
1. Group Your SQL Queries
You've probably heard of N+1 queries.
That's when an innocuous looking line of code (like the following) triggers many more queries than you expect.
Client.limit(10).map(&:address)
In this case, instead of doing a single query with a join for the addresses, you make a query for 10 clients, and then another query to get the address for each of the clients.
SELECT * from "clients" LIMIT 10;
SELECT * from "addresses" WHERE "id" = 7;
SELECT * from "addresses" WHERE "id" = 8;
SELECT * from "addresses" WHERE "id" = 10;
SELECT * from "addresses" WHERE "id" = 12;
SELECT * from "addresses" WHERE "id" = 13;
SELECT * from "addresses" WHERE "id" = 15;
SELECT * from "addresses" WHERE "id" = 16;
SELECT * from "addresses" WHERE "id" = 17;
SELECT * from "addresses" WHERE "id" = 21;
You might have heard about this problem, and that the solution to the problem is the Rails eager loading API:
Client.includes(:address).limit(10)
This produces just two queries:
SELECT * from "clients" LIMIT 10;
SELECT * from "addresses" WHERE "client_id" IN (7, 8, 10, 12, 13, 15, 16, 17, 21);
Here's where it gets interesting. Skylight has a feature that we designed to detect N+1 queries, but we built it generically to detect any duplicate queries (after dynamic values are parsed out).
The feature did successfully detect a number of N+1 queries, but there was an unexpected bonus: we learned that other kinds of duplicate queries were just as common. And just as you can improve overall performance by grouping together N+1 queries, you can typically improve performance by grouping together any repeated queries as well.
When you're using Skylight, keep an eye out for the database icon () on your dashboard. It means we've detected a repeated query in that endpoint. If you click on the endpoint, you can see the exact SQL query that was repeated.
2. Use Basic Fragment Caching
One of the easiest ways to get a decent performance boost out of a Rails app is to find ways to cache expensive HTML or JSON fragments.
To get the best bang for your buck, you should focus your energy on areas of your templates that are doing the most work. If you're using Skylight, you can look at your endpoint's Big Picture to get a sense of which templates to focus on.
In this example, there are six different HTML fragments, but the bulk of the time is actually spent in just two of them.
Note the red repetition icon next to the SQL query. This is what
we discussed in the previous section; it indicates that Skylight
has identified a repeated query that you can group together.
Looking into static/index.html.erb
, I see a few truly dynamic bits, like this:
<%= render partial: 'shared/flash',
locals: { flash: flash, class_name: "banner" } %>
But for the most part, it's a large template whose dynamic bits look like this:
<%= link_to "Sign Up For Free", signup_path, class: 'signup' %>
If you're like me, even talking about caching feels daunting. But Rails makes it really easy!
Just wrap the area of the template that you want to cache with a cache
block.
<% cache(action_suffix: "primary") do %>
<section class="hero">
<div class="container">
...
</div>
</section>
<section class="data">
<div class="container">
...
</div>
</section>
...
<% end %>
When using fragment caching, remember three things:
- Pick a key that describes the fragment you are caching.
You can useaction_suffix
, as in this example, if the key is
unique only inside of this action. (You can also use an
Active Record object as your cache key, which is quite
convenient and simple in the right situations.) - The easiest caching backend is memcached. This is because
memcached automatically expires keys that haven't been used in
a while (an "LRU" or "least recently used" expiration strategy),
and cache expiration is the hardest part of a good caching
strategy. - Focus on the big spenders. It's tempting to spend a lot of
time caching all of your HTML and trying to identify good cache
keys for everything. In practice, you can get big wins by just
caching expensive fragments that have easy cache keys (either
because the template is relatively static, or because it's
derived from an Active Record object, which has built-in caching
support).
3. Eliminate Memory Bloat
Even if you're using a performance monitoring tool, it's very easy for the cost of memory bloat to go unnoticed.
That's because endpoints that create a huge amount of objects aren't necessarily the endpoints that experience the GC pauses. GC pauses are spread out throughout your entire app, so identifying the root cause can be tricky.
You can use gems like Sam Saffron's memory_profiler
or Koichi Sasada's allocation_tracer
to try to track down which actions are generating objects, and there are even Rack middlewares you can install that will collect the information automatically.
If you run these tools locally, make sure to run your app in production mode, because development mode creates all kinds of garbage in order to support hot reloading.
If you're using Skylight with Ruby 2.1 or higher, we have an allocation mode that you can use to identify actions and events that are generating a lot of objects.
When you select Allocations mode, Skylight re-sorts your endpoints by the number of allocations generated, so you can quickly drill into the ones creating the most garbage.
Once you've drilled in, Skylight shows the events in the endpoint sized in terms of number of allocations (rather than time spent).
In this case, we can see that the bulk of the 308,000 allocations generated for this request happened in a single template. Incidentally, that was the same template that we targeted for caching in Step 3. Caching this template will improve both time spent for this endpoint and reduce GC pauses across the application.
4. Move Third-Party Integration to Workers
Or, put another way: do as little as possible in your request.
One of the biggest things we did to improve the Skylight Rails app over time was to move third-party integrations (like updating Mixpanel/Intercom or notifying Slack) from the request itself into a worker.
When you’re just starting out, it’s too easy to make synchronous HTTP requests inside your requests, because why not? Getting a worker setup up and running takes time and it adds operational cost to your app when you're still trying to get it off the ground (usually with a tiny team).
But once you get going, synchronous HTTP requests are one of the biggest culprits when it comes to slow requests. It's also easy to lose track of them because you're likely synchronizing with third party services in before_filter
s or middlewares, both areas of code you don't look at very often.
If you're using Skylight, be on the lookout for grey boxes indicating synchronous HTTP requests:
In most cases, you can collect quick wins by moving this work from your request/response cycle into a background worker.
If you feel daunted by the process of getting background jobs up and running, don't! It's one of the highest leverage improvements you can make to a Rails app. Once you have the ability to send work to background jobs, you'll be surprised how often you use it.
If you're using Rails 4.2 or newer, ActiveJob makes the process even simpler. Rails now bakes the notion of background jobs into the framework, complete with generators to get your started and seamless integration into the rest of Rails. I strongly recommend it.
Haven't tried Skylight yet? Dial your Rails apps to 11 by signing up for a free 30-day trial.