Rodrigo Rosenfeld Rosas
Why is your Rails app boot slow?
In my previous article we explored how we can build huge web app monoliths with Ruby that can complete simple request tests in under a second. In this article we’ll focus on Rails apps. We’ll identify what causes a big app to take a long time to boot and run simple tests and what we can do to improve the situation.
How fast can Rails boot?
Let’s start by investigating how fast we can expect a Rails app to boot. Let’s check against a minimal Rails app first:
1 | require 'bundler/inline' |
2 | |
3 | ENV["BOOTSNAP_CACHE_DIR"] ||= "tmp/cache" |
4 | |
5 | gemfile do |
6 | gem 'bootsnap', require: 'bootsnap/setup' |
7 | gem 'minitest', require: 'minitest/autorun' |
8 | gem 'ostruct', require: false |
9 | gem 'rack-test', require: 'rack/test' |
10 | gem 'railties', require: 'rails' |
11 | gem 'actionpack', require: 'action_controller/railtie' |
12 | end |
13 | |
14 | class MinimalApp < Rails::Application |
15 | config.root = __dir__ |
16 | config.eager_load = false |
17 | config.hosts << "example.org" |
18 | end |
19 | |
20 | MinimalApp.initialize! |
21 | |
22 | MinimalApp.routes.draw do |
23 | get 'up' => 'rails/health#show' |
24 | end |
25 | |
26 | describe "/up" do |
27 | include Rack::Test::Methods |
28 | def app = MinimalApp |
29 | |
30 | it "responds with the server status" do |
31 | get "/up" |
32 | assert last_response.ok? |
33 | end |
34 | end |
Let’s run this test:
/usr/bin/time ruby minimal_test.rb
Run options: --seed 43022
# Running:
.
Finished in 0.066011s, 15.1490 runs/s, 15.1490 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
1.32 real 0.57 user 0.49 sys
We can’t get subsecond testing with Rails using hardware commonly used in a development environment, like we did with Rack and Roda in my previous article, but we can get pretty close with a minimal Rails app. How about a full Rails app?
rails new full_rails_app
cd full_rails_app
Add this test file to the project:
1 | # test/system/health_test.rb |
2 | |
3 | require "application_system_test_case" |
4 | |
5 | class HealthCheckTest < ApplicationSystemTestCase |
6 | driven_by(:rack_test) |
7 | |
8 | test "returns the server status" do |
9 | visit "/up" |
10 | assert_selector "body", style: "background-color: green" |
11 | end |
12 | end |
Let’s test it:
/usr/bin/time bin/rails test:all
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 16351
# Running:
.
Finished in 0.141745s, 7.0549 runs/s, 7.0549 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
2.48 real 1.13 user 1.02 sys
Not that bad. This is what we can expect from a fresh full Rails app. If we can keep our tests running that fast, that should be good enough.
How about Spring?
If you’re reading this article, chances are that you are from the days where Spring was added by default when you ran “rails new app”. You might be asking: what about Spring?
Let’s add spring and measure again.
echo 'gem "spring", group: :development' >> Gemfile
bundle
bundle exec spring binstub
/usr/bin/time bin/spring rails test test/system
Running via Spring preloader in process 63159
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 14564
# Running:
.
Finished in 0.166576s, 6.0033 runs/s, 6.0033 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
0.97 real 0.18 user 0.11 sys
Yay, there we go, subsecond testing with Rails!
Why can’t we use “bin/spring rails test:all” (or test:system
)? Spring supports specific
Rails commands such as “test”, “console” and “runner”, but not all commands by default.
Running “test:system”, “test:all”, “routes” and other commands will not go through the
Spring server.
For those that don’t know what Spring is, it preloads the Rails app, initializes it (boot) and waits for commands. This is the Spring server. Then, the Spring client will send the commands to the server, which will then fork and run the command after the application has already been initialized. That’s why it can complete tests in under a second, since the app was already initialized when the command was run. It also watches some files such as the initializers and will restart the server when those files change.
Why isn’t Spring bundled with Rails by default anymore? I don’t really know, but Spring is actually a hack. You have to extend it to support more commands, such as “rspec” and “cucumber”, by installing additional gems, or write your own if you want to add support for “test:system” and “test:all”, for example.
There are over a hundred open issues in the Spring repository to this date, which indicates that such a hack is not as robust as one might think. Having said that, if your application loads a lot of code (gems, initializers, etc) during its initialization, adding Spring to your app can significantly improve your test boot time with minimal effort. But if you’re able to keep your boot fast, that’s certainly better since all commands will benefit from it, not just those supported by Spring, and you avoid all Spring pitfalls.
Anti-patterns often used in Rails apps
Let’s talk about some common patterns found in old and big Rails apps that explain why even very simple tests can take several seconds to complete. Those are most likely the reason why a Rails app takes so long to boot.
Auto-required gems
In Rails, every gem added to the default and environment groups in Gemfile will be
automatically required during the application initialization. This happens through this
line in config/application.rb
:
1 | Bundler.require(*Rails.groups) |
“Rails.groups” defaults to [:default, "development"]
when running Rails in the
development environment, for example.
The more gems you add to the Gemfile default groups, the longer it will take for the application to initialize. The application will boot faster if we add “require: false” to the gem declaration, or if we create a separate group such as:
1 | # Gemfile |
2 | # ... |
3 | group :lazily_loaded_gems |
4 | gem "graphql" |
5 | gem "sidekiq" |
6 | # ... |
7 | end |
For the production environment, and for the test environment when ENV["CI"]
is
present, we could add the :lazily_loaded_gems
group to the default Rails groups. For
example, you can replace the Bundler.require
line in config/application.rb
with:
1 | # config/application.rb |
2 | # ... |
3 | bundler_groups = Rails.groups |
4 | bundler_groups << :lazily_loaded_gems if Rails.env.production? || ENV["CI"].present? |
5 | |
6 | Bundler.require(*bundler_groups) |
The downside is that this will force us to explicitly require those gems when our code depends on them, or we’ll get errors in the development and test environments.
Rails initializers
Here’s what the documentation has to say about initializers:
https://guides.rubyonrails.org/configuring.html#using-initializer-files
After loading the framework and any gems in your application, Rails turns to loading initializers. An initializer is any Ruby file stored under config/initializers in your application. You can use initializers to hold configuration settings that should be made after all of the frameworks and gems are loaded, such as options to configure settings for these parts.
Lots of gems will then suggest you to add some initializers to your project. The Devise generator will add this initializer.
The “mongoid:config” generator will add another initializer. The carrier_wave
gem will
suggest another initializer to configure the gem. Same happens for many other gems such
as airbrake
, simple_form
, draper
, geocoder
, kaminari
, money
and many more
popular gems. Add to this the project’s own internal gems. Now, a very simple test to
check the “/up” health-check endpoint ends up loading lots of irrelevant code to the
test. As a result, such a single test would take a long time to complete.
One should carefully decide whether it’s worth adding a new initializer to the application. Adding an initializer is very simple and allows us to quickly move on, but at the cost of slowing down the app’s boot a little bit more.
Just like with the issue of gems getting required by default, we can opt out by avoiding creating such initializers whenever possible. And the same drawback applies to this case, as we’re supposed to initialize those gems whenever our code depends on them.
For example, suppose we have the following initializer in our app:
1 | # config/initializers/airbrake.rb |
2 | |
3 | Airbrake.configure do |
4 | # ... |
5 | end |
We could instead replace it with:
1 | # we want to enable Airbrake during the application |
2 | # initialization in the production environment: |
3 | require "setup_airbrake" if Rails.env.production? |
Then, we create “lib/setup_airbrake.rb”:
1 | require "airbrake" |
2 | Airbrake.configure do |
3 | # ... |
4 | end |
Suppose we have some custom filter configured for Airbrake and we want to test it. Then, in our test we would do something like:
1 | # spec/params_sanitizer_airbrake_filter_spec.rb |
2 | require "rails_helper" |
3 | require "setup_airbrake" |
4 | |
5 | RSpec.describe ParamsSanitizerAirbrakeFilter do |
6 | # ... |
7 | end |
In many cases we can completely get rid of lots of initializers. For example, there’s no need to initialize geocoder if we’re not using it. Just require “setup_geocoder” once you depend on it somewhere.
There are other cases where they can’t be easily avoided though. For example, some gems must be loaded before we define the app’s routes, which happens during the initialization. It doesn’t matter if our test isn’t exercising a GraphQL controller, or some controller protected by Devise, we would still have to load both gems so that we can define the app’s routes. Unfortunately I’m not aware of any tricks we could apply to lazily load such code.
How about simple_form
? It’s not required by the routes. What if you’re testing an
api-only controller? Is it required to load simple_form
? Well, an option would be to
prepend ApplicationController
with:
1 | require "setup_simple_form" |
This way, if you’re testing some controller inheriting from APIController
instead, then
it won’t load simple_form
.
Surely this approach requires discipline but it also enables your individual tests to complete much faster since they no longer must require all of the application’s dependencies if the code you’re testing does not depend on them.
RSpec support files
This is similar to the Rails initializer pattern. Up to rspec-rais
3.0.2, released 10
years ago, we had the following line in spec/rails_helper.rb
enabled by default:
1 | Dir[Rails.root.join("spec/support/**/*.rb")\].each { |f| require f } |
If your app is that old, chances are good that you have such a line still enabled in your project and it leads to the same sort of issues as the Rails initializers described above.
These days such a line is commented by default in the generated file. See the relevant snippet:
1 | # The following line is provided for convenience purposes. It has the downside |
2 | # of increasing the boot-up time by auto-requiring all files in the support |
3 | # directory. Alternatively, in the individual `*_spec.rb` files, manually |
4 | # require only the support files necessary. |
5 | # |
6 | # Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } |
Some people will ignore the warning and uncomment that line to these days and will experience the same issue with boot time performance when running individual tests.
Another possibility would be to create a similar tool to the spring
gem. That tool
would load all support files and start a server waiting for a command to run some tests.
Then it would fork the process and load the requested tests much faster. It should
restart the server whenever the support files change. I don’t think such a tool exists
yet, but the effort to create one would be similar to creating the spring
gem. Not
trivial, but not that much complicated either. It’s certainly doable.
Alternatively, the following simple script would speed up running your tests multiple
times. Despite the name, it doesn’t really watch for file changes like
guard, although such a feature could be added to the
script, but I wanted to keep it simple since this article is already long enough. It’s
important to change config.enable_reloading
in config/environments/test.rb
to
something like:
1 | config.enable_reloading = ENV["WATCHING_SPECS"] || defined?(Spring) |
Then, such script should work fine in most cases:
1 | #!/usr/bin/env ruby -I spec |
2 | |
3 | require "bundler/setup" |
4 | ENV["BOOTSNAP_CACHE_DIR"] = File.expand_path "../tmp/cache", __dir__ |
5 | require "bootsnap/setup" |
6 | require "rspec/core" |
7 | |
8 | ENV["WATCHING_SPECS"] = "true" |
9 | |
10 | require "rails_helper" |
11 | |
12 | ActiveRecord::Base.connection_pool.disconnect |
13 | |
14 | ARGV << "spec" if ARGV.empty? |
15 | last_specs = ARGV |
16 | |
17 | while true |
18 | Process.fork do |
19 | RSpec::Core::Runner.run last_specs |
20 | end |
21 | Process.wait |
22 | |
23 | puts "Which tests to run? Press ENTER to run the same previous tests: #{last_specs}.\n" + |
24 | "Enter 'exit' or Ctrl+D to stop. 'reset' to run the original tests: #{ARGV}." |
25 | specs = STDIN.gets&.chomp&.split(" ") |
26 | break if specs.nil? || specs == [ "exit" ] |
27 | specs = last_specs if specs.empty? |
28 | specs = ARGV if specs == [ "reset" ] |
29 | specs = specs.map do |spec| |
30 | next spec if spec.start_with?('-') |
31 | spec.start_with?("spec") ? spec : "spec/#{spec}" |
32 | end |
33 | |
34 | last_specs = specs |
35 | |
36 | RSpec.configuration.start_time = Time.now |
37 | |
38 | Rails.application.reloader.reload! |
39 | end |
Ten years ago we had spork which took a similar
approach to Spring, towards improving test boot time, but that project seems to be dead
since then. The rspec-rails
gem also used to provide a script/spec_server
script by
that time with similar goals. As we can see, the Rails boot time has been a concern for
over a decade. Hopefully it will get fixed once and for all some day.
Factories with provided classes
With FactoryBot one can specify which class to use for a particular factory:
1 | FactoryBot.define do |
2 | factory :fat_model do |
3 | name { "MyText" } |
4 | |
5 | factory :fat_model_subclass, class: FatModelSubclass do |
6 | end |
7 | end |
8 | end |
This causes FatModelSubclass to be loaded with all its dependencies (FatModel) when loading the factory’s definitions, even if we’re not using that factory. There are better ways to ensure the class will be lazily loaded, once the factory is actually used:
Example 1 — pass class as a string to be lazily loaded when needed:
1 | factory :fat_model_subclass, class: "FatModelSubclass" do |
2 | end |
This is documented by the way:
You can pass a constant as well, if the constant is available (note that this can cause test performance problems in large Rails applications, since referring to the constant will cause it to be eagerly loaded).
Example 2 — use initialize_with
:
1 | FactoryBot.define do |
2 | factory :fat_model do |
3 | name { "MyText" } |
4 | transient do |
5 | klass { FatModel } |
6 | end |
7 | |
8 | initialize_with { klass.new } |
9 | |
10 | # factory :fat_model_subclass, class: "FatModelSubclass" do |
11 | factory :fat_model_subclass do |
12 | klass { FatModelSubclass } |
13 | end |
14 | end |
15 | end |
The second approach allows some code editors to go to the model definition by using some
shortcut such as Ctrl/Cmd + Click
.
Rails limitations
Routes
Sometimes we just want to test some models, but some dependency would rely on Rails somehow (a secret, setting or environment, for example) and we end up requiring “rails_helper” even though we’re not making any requests. But we still must initialize the Rails app, and it happens to load the routes, which can take a significant time in large apps.
This has been fixed in Rails 8 (not released yet to the date of publication of this article) but in the meanwhile, we can use the routes_lazy_routes gem, by Akira Matsuda:
In Gemfile, we add:
1 | group :development, :test do |
2 | gem 'routes_lazy_routes' |
3 | end |
With that change alone, running “bin/rails environment” goes from 2.5s to 1.9s.
Even though it speeds up some Rails commands, it will still load the routes when we’re
testing some model after requiring rails_helper
, simply because that gem has this in
its initializer:
1 | ActiveSupport.on_load :action_dispatch_integration_test, run_once: true do |
2 | RoutesLazyRoutes.eager_load! |
3 | end |
Surely, it could be adapted for this use case, but to be honest, it’s better to wait for Rails 8 release date.
Mountable Engines
Once you run the “graphql:install” generator, it will add the “graphiql-rails” gem to the development group. It’s a mountable Rails Engine. I don’t know how we could possibly lazily load mountable engines in a Rails application. If your app depends on many mountable engines, they will end up adding to the boot time, since you must load them during the app’s initialization. If you know how to lazily load them, please let me know in the comments.
Profiling
When we’re investigating what’s causing some code to be slow, it’s very important to profile the relevant code. If we want to know why boot is taking a long time, we want to profile at least:
- require “bundler/setup” — There’s usually little we can do about it unless you’re intending to work directly on the Bundler’s source-code;
- Bundler.require(…) — We can decide which gems should be loaded when this method is
called by making changes to either Gemfile or to the code calling
Bundler.require
. - Rails initializers — There are two simple ways one can measure this. One of them
would be to create a single initializer that would load the initializers from another
path and measure how long they take to complete. The other one is installing the gem
bumbler
which I’ll discuss briefly in the next section. Or we can simply profile the next items in this list, which should include the time spent on initializers: - require “config/environment”
- Rails.application.initialize!
Bumbler
Bumbler is a tool that allows us to quickly inspect how much time is spent on initializers and required gems:
bundle exec bumbler --initializers # display initializers
# specify a minimal threshold with -t 20 to display only initializers taking over 20ms to load.
bundle exec bumbler --initializers -t 20
# display all loaded gems:
bundle exec bumbler --all
It doesn’t provide as many details as the flamegraphs generated by Stackprof, but they can quickly provide you with some hints on what’s going on with your app’s boot.
Stackprof
I prefer to profile code using flamegraphs, so my suggestion is to add the stackprof gem to Gemfile and instrument the relevant code like this:
1 | require 'stackprof' |
2 | require 'json' |
3 | |
4 | GC.disable |
5 | profile = StackProf.run(raw: true) do |
6 | Rails.application.initialize! |
7 | end |
8 | |
9 | File.write "profile-application-initialize.json", JSON.unparse(profile) |
Then we can use another tool to view the flamegraphs:
npm -g speedscope
speedscope profile-application-initialize.json
The stackprof’s README mentions another way to generate and visualize the flamegraphs, but I couldn’t make it work in my environment, while speedscope did the trick for me.
If you decided to profile your app’s boot process in Linux or Mac OS, you probably
noticed that loading the timezone datasource takes over 100 ms in the Rails boot. To save
that time I’d recommend you to add the tzinfo-data
gem to Gemfile. Rails by default
adds this to Gemfile:
1 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem |
2 | gem "tzinfo-data", platforms: %i[ windows jruby ] |
Using this gem as the datasource will load much faster in Linux/Mac compared to loading the datasource from the system files, so I’d recommend adding this gem to Gemfile for all platforms if you want to speed up your boot time as much as possible. If you still wants to use the system files as the datasource for the production environment, just add this line for this environment:
1 | TZInfo::DataSource.set :zoneinfo |
Conclusion
Just like mentioned in my previous article, the key for fast booting is lazily loading code. Loading code takes time, so the more code you can avoid loading during the app initialization, the faster it will boot. Avoid initializers as much as you can, require gems only once you need them and you should benefit from being able to run individual tests pretty fast.
Adding the spring gem to the project could also save you a few seconds running your tests. A “bin/watch-specs” script was provided to get a similar experience focused on speeding up the tests boot time.
Finally, I created a repository with all changes discussed in this article to help you explore what it means in practice. Check it out and try it by yourself. It may be easier to follow the project by inspecting the git logs and see the changes one by one.
If you have any other suggestions to improve the boot performance, please let us know in the comments. I’d love to hear about them.
Good luck improving your app’s boot performance.