Rodrigo Rosenfeld Rosas

Why is your Rails app boot slow?

Fri, 25 Oct 2024 20:35:00 +0000

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:

1require 'bundler/inline'
2
3ENV["BOOTSNAP_CACHE_DIR"] ||= "tmp/cache"
4
5gemfile 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'
12end
13
14class MinimalApp < Rails::Application
15 config.root = __dir__
16 config.eager_load = false
17 config.hosts << "example.org"
18end
19
20MinimalApp.initialize!
21
22MinimalApp.routes.draw do
23 get 'up' => 'rails/health#show'
24end
25
26describe "/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
34end

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
3require "application_system_test_case"
4
5class 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
12end

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:

1Bundler.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# ...
3group :lazily_loaded_gems
4 gem "graphql"
5 gem "sidekiq"
6 # ...
7end

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# ...
3bundler_groups = Rails.groups
4bundler_groups << :lazily_loaded_gems if Rails.env.production? || ENV["CI"].present?
5
6Bundler.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
3Airbrake.configure do
4 # ...
5end

We could instead replace it with:

1# we want to enable Airbrake during the application
2# initialization in the production environment:
3require "setup_airbrake" if Rails.env.production?

Then, we create “lib/setup_airbrake.rb”:

1require "airbrake"
2Airbrake.configure do
3 # ...
4end

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
2require "rails_helper"
3require "setup_airbrake"
4
5RSpec.describe ParamsSanitizerAirbrakeFilter do
6 # ...
7end

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:

1require "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:

1Dir[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:

1config.enable_reloading = ENV["WATCHING_SPECS"] || defined?(Spring)

Then, such script should work fine in most cases:

1#!/usr/bin/env ruby -I spec
2
3require "bundler/setup"
4ENV["BOOTSNAP_CACHE_DIR"] = File.expand_path "../tmp/cache", __dir__
5require "bootsnap/setup"
6require "rspec/core"
7
8ENV["WATCHING_SPECS"] = "true"
9
10require "rails_helper"
11
12ActiveRecord::Base.connection_pool.disconnect
13
14ARGV << "spec" if ARGV.empty?
15last_specs = ARGV
16
17while 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!
39end

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:

1FactoryBot.define do
2 factory :fat_model do
3 name { "MyText" }
4
5 factory :fat_model_subclass, class: FatModelSubclass do
6 end
7 end
8end

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:

1factory :fat_model_subclass, class: "FatModelSubclass" do
2end

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:

1FactoryBot.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
15end

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:

1group :development, :test do
2 gem 'routes_lazy_routes'
3end

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:

1ActiveSupport.on_load :action_dispatch_integration_test, run_once: true do
2 RoutesLazyRoutes.eager_load!
3end

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:

1require 'stackprof'
2require 'json'
3
4GC.disable
5profile = StackProf.run(raw: true) do
6 Rails.application.initialize!
7end
8
9File.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
2gem "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:

1TZInfo::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.

Powered by Disqus