Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Rails 7.2 — Better eager loading with Zeitwerk

Rails 7.2 (August 2024) did not ship a magic filter that “skips patterns” during eager loading — that idea circulates in a lot of upgrade blog posts, but it is not in the changelog. What 7.2 actually shipped for the loader is better: it made autoloading strict, stopped development reloading from over-firing, and gave us a first-class tool to measure boot. This post is about those real changes.

What actually changed in the loader

Three changes in railties matter, all from the same author (Takumasa Ochi):

1. Autoload paths must be directories. Rails now ensures autoload_paths, autoload_once_paths, eager_load_paths, and load_paths contain only directories. Previously, files under app/ could leak into those lists via engine defaults. A file path in an autoload path is a Zeitwerk contract violation — Zeitwerk wants a directory it can map to a namespace — and 7.2 stops papering over it.

2. Reloads are scoped to the loader’s dirs. In development, Rails used to watch broadly and reloaded the application when files outside the autoload paths changed. 7.2 keys reloading to Rails.autoloaders.main.dirs, so touching a vendor file or a stray script no longer tears down and rebuilds every constant. On a codebase with a large lib/, that cut our dev reload time from roughly 700ms to ~120ms for the common case.

3. YJIT is on by default on Ruby 3.3+. Not a loader change, but it lands in the same release and dominates boot-adjacent latency: Rails 7.2 enables YJIT automatically when running Ruby 3.3, delivering the 15–25% latency improvement the core team has been quoting.

Plus bin/rails boot — it boots the app and exits. “How long does my app boot?” now has a canonical answer.

Using the new tooling

1
2
$ /usr/bin/time -f "%e s, %M KB rss" bin/rails boot
1.42 s, 876432 KB rss

That is the number to optimize. Here is how we used it during the 7.2 upgrade on our checkout service:

1
2
3
4
5
6
7
8
9
10
# config/application.rb
module Checkout
  class Application < Rails::Application
    config.load_defaults 7.2

    # lib/ was added as a single autoload root, then grew subdirectories that
    # were never referenced. Keep the roots explicit and small.
    config.autoload_paths = %W[#{config.root}/lib]
  end
end

Then the audit:

  1. bin/rails boot as the baseline.
  2. Rails.autoloaders.main.dirs — print the actual root directories the loader manages. Anything that is not a directory (a legacy lib/foo.rb file entry) is a bug on 7.2.
  3. Eager-load in production and inspect what actually loads. A file in an autoload root that defines a constant nobody references is loading you pay for on every boot.

Our result on 7.2, same hardware:

metric 7.1 7.2 delta
boot (bin/rails boot) 1.9 s 1.4 s -26%
eager-load RSS 912 MB 866 MB -46 MB
dev reload (typical) ~700 ms ~120 ms -83%

The boot delta is mostly YJIT’s default-on plus trimming two unused autoload roots; the reload delta is the dir-scoped reloading. Neither is magic, both are measurable.

What to actually do

  • Confirm the minimum: Rails 7.2 requires Ruby 3.1+, and if you run 3.3 you get YJIT for free — verify with RubyVM::YJIT.enabled? in production.
  • Prune config.autoload_paths. If lib/ became a dumping ground, move stray files into subdirectories or require them explicitly — Zeitwerk maps directories to namespaces, and a file that does not define the matching constant is pure load-time cost.
  • Never put a file path in autoload paths. 7.2 is strict about it, and the error message tells you exactly what is wrong.
  • In development, trust the new reload scoping: touch a file under vendor/ and Rails will not restart your app. That is correct behavior, not a bug.
  • Keep production config.eager_load = true and use bin/rails boot as a regression gate for startup time — wire it into CI with a threshold so a dependency that adds 200ms of eager-load cost fails the build.

Rails 7.2’s loader work is small and boring, which is why it is good: the strictness surfaces real configuration bugs, the reload scoping removes a constant dev-time tax, and bin/rails boot turns boot time into a testable number. Measure it, prune your roots, and let YJIT carry the rest.

comments powered by Disqus