Get in, human: cut Rails boot time with require-profiler and this guide

Every Rails app race starts the same: load code, read config, then the green flag. But the bigger the app, the more startup drags on deploys, CI, and developer patience. This is doubly so as AI agents boot your app far more often than humans do. Thus, meet require-profiler, a new one-stop profiler for Ruby’s code loading process! Grab it first when optimizing boot, leaving sampling profilers for the deep dives. We’ll show the telemetry from real projects: one cut the dev boot of Factorial’s 200-component monolith by 40%. And read till’ the final lap: profiling an AnyCable-powered app’s boot uncovered a surprise in Ruby itself!
Now the usual objection: nobody has time to tune boot. It sounds like a week of work with no guaranteed payoff (though here the prize compounds with every boot, every day). But with the right tools, your first pass takes an afternoon, and most of the fixes in apps are one-liners. (Besides, the framework’s creator is literally a Le Mans class-winning driver. Speed is practically canon.)
Lap 0: Bootsnap
Before entering the profiling race, make sure Bootsnap is on and working. It’s the easiest possible win at the start. Legacy apps occasionally run with it missing entirely (we’ve seen it multiple times), and that’s where the huge wins hide. On Factorial’s monolith, the same boot runs 2.6 times slower without it. It ships enabled in new Rails apps, so the checklist is really two items: keep it on and updated, and precompile it properly for production.
We put updated in that checklist on purpose. The ecosystem’s common curse is that gems ship speedups, and nobody collects them. We’ll meet this pattern again several times in this post. In Bootsnap’s case, recent versions roughly halve the load-path scan by cutting stat syscalls (an optimization also headed to Ruby 4.1 as a new Dir.scan). Bump the gem to at least 1.20.0 and win your first seconds.
Now for production, don’t forget precompilation. In development the cache warms up automagically. But a production image gets no warm-up laps, so “pre-warm” the cache during the Docker build:
bundle exec bootsnap precompile --gemfile app/ lib/
Include all the code directories (i.e., config, engines, packs, and other custom top-level directories). To verify nothing is missed, enable the instrumentation and boot:
# config/boot.rb
Bootsnap.instrumentation = ->(event, path) { puts "#{event} #{path}" }
rm -rf tmp/cache/bootsnap/*
bundle exec bootsnap precompile --gemfile app/ lib/
bundle exec rails runner 'puts :ok' | grep 'miss'
And watch your Docker setup. The cache lives in tmp/, and that location invites accidents: a multi-stage COPY can skip it, a cleanup step can wipe it, and a volume can shadow it at runtime.
Lap 1: require-profiler
For years, newbies started their boot profiling races with Bumbler. It’s a flat list of per-gem require times, plus a separate mode for initializers. But it never tells the whole story, though. Transitive requires get blurred, whatever a gem does while loading hides inside one opaque number, and the Ruby code that runs past the requires remains totally invisible.
On Factorial’s monolith (before the pit stop), it flagged just six gems, barely over a second combined, rails itself and anycable-rails among them (remember that last one for the final lap). Not much to act on when the stopwatch showed ~22 seconds with Bootsnap enabled.
So, let’s welcome a new contender to the race: require-profiler, an easy-to-use one-stop shop for nearly every boot issue. It profiles Ruby’s code loading itself: every require, require_relative, and load, as a tree, with timings. And a plugin system covers the costs requires alone can’t see. The gem also has a fascinating origin story.
It was built on require-hooks, a universal way to intercept Ruby’s code loading process. Despite Ruby’s famous flexibility, overriding the loading is officially unsupported. Yet everyone seems to love patching it anyway (RubyGems, Zeitwerk, Bootsnap), and resolving the resulting conflicts is hacky as hell. So Vladimir Dementyev made the case for a proper loader API in Ruby itself at RubyKaigi 2026, with require-profiler premiering as the talk’s bonus track.
Add it to the Gemfile and point it at your app:
bundle exec ruby -r./config/boot -require-prof config/environment.rb
Loading config/boot.rb first sets up Bundler and Bootsnap (remember the conflicting patches? Bootsnap must hook in first). Then everything starting from config/environment.rb gets profiled. (If the app still carries Spring, disable it while profiling with DISABLE_SPRING=1, otherwise you’re timing a fork, not a boot.)
The output is an indented tree: one line per loaded file with its time in milliseconds, self plus children, and a few synthetic lines mixed in. The rough shape is this:
entry_file.rb — self + children time, in ms
required_file.rb — ...
parsed_file.yml — ...
prefix:synthetic_line — ... (an HTTP call `http:` or a Rails phase `rails:`)
And here’s a slice of Factorial’s pre-pit-stop real profile (shortened):
config/application.rb — 6198.333ms
components/integrations_google_users/lib/integrations_google_users.rb — 177.365ms
gems/google-apis-admin_directory_v1-0.42.0/lib/google/apis/admin_directory_v1.rb — 125.054ms
rails:initializer:load_config_initializers:gems/railties-7.2.2.2/lib/rails/engine.rb:640 — 791.744ms
config/initializers/countries.rb — 1369.053ms
gems/faker-2.19.0/lib/locales/ja/address.yml — 357.229ms
gems/faker-2.19.0/lib/locales/es.yml — 6.947ms
config/initializers/http_showcase.rb — 39.739ms
http:GET:https://www.ruby-lang.org/ — 11.574ms
rails:initializer:run_prepare_callbacks:gems/railties-7.2.2.2/lib/rails/application/finisher.rb:73 — 4872.904ms
rails:to_prepare:gems/karafka-2.5.2/lib/karafka/railtie.rb:87 — 830.3ms
karafka.rb — 830.111ms
rails:load_hook:after_initialize:karafka.rb:20 — 705.965ms
The slice above shows all four kinds of boot cost. Plain requires sit at the top: here, the Google gem load. The rails: lines track the whole Rails initialization pipeline: initializers, then reload callbacks, finally load hooks. The YAML lines are files parsed during boot, better caught on a cold rerun since Bootsnap can make them too fast to notice. And the http: lines are network calls made along the way; ours is planted for the showcase, though real ones sneak into apps surprisingly often.
Still overwhelmed? The real report goes on like this for thousands of lines, drowning real issues in harmless ones. You can filter out the noise: REQUIRE_PROFILE_THRESHOLD drops everything faster than N milliseconds, and REQUIRE_PROFILE_FOCUS zooms into a pattern, ancestors included, so chains stay readable:
# Only files that took 100ms or more
REQUIRE_PROFILE_THRESHOLD=100 bundle exec ruby -r./config/boot -require-prof config/environment.rb
# Zoom into a single gem, full require chains included
REQUIRE_PROFILE_FOCUS="aws" bundle exec ruby -r./config/boot -require-prof config/environment.rb
Radio channel clear: “Okay, filters on and telemetry reads clean. One kind of line explained at a time, please.”
The requires: the heaviest gems
components/integrations_google_users/lib/integrations_google_users.rb — 177.365ms
gems/googleauth-1.5.2/lib/googleauth.rb — 51.428ms
gems/google-apis-admin_directory_v1-0.42.0/lib/google/apis/admin_directory_v1.rb — 125.054ms
gems/hexapdf-1.7.0/lib/hexapdf.rb — 62.873ms
gems/caxlsx-3.4.1/lib/caxlsx.rb — 51.834ms
gems/savon-2.15.0/lib/savon.rb — 92.329ms
gems/elasticsearch-8.13.0/lib/elasticsearch.rb — 104.781ms
gems/elasticsearch-api-8.13.0/lib/elasticsearch/api.rb — 98.503ms
gems/karafka-web-0.11.4/lib/karafka/web.rb — 243.248ms
gems/karafka-2.5.2/lib/karafka.rb — 142.431ms
lib/frontend_navigation.rb — 75.717ms
You’ll meet the same offenders in app after app. The heaviest are usually generated API gems (a favorite sin of cloud providers), heavy-processing libraries (PDF, XLSX, etc.), and big transport clients like savon (SOAP), elasticsearch, and karafka (Kafka). Luckily, the majority of them are rarely needed at boot, and some gems can be partially loaded.
For internal dev tooling (admin UIs, bullet, console enhancements, stray test tooling, and the like), gem groups are a solution when the default environment can skip a dependency. The habit to build: keep an eye on heavy dependencies, and when only one corner of the app needs them, load them there instead of on every boot.
Gems aren’t the only suspects, though. Any plain app file can lose time the same way, because everything at class or module level runs during the require. The lineup’s last line is a homegrown example: lib/frontend_navigation.rb, a generated map of every frontend route that lets backend code build typed links into the UI. Factorial pays for it in boot time by defining thousands of classes at load time.
So, big DSLs are a high-risk tax to pay. Like muscle cars. Impressive, but thirsty. GraphQL, Grape, and Active Admin are the notorious ones that we’ve met each on different projects. These parts aren’t always easy to delay or skip conditionally, but manual optimizations and caches are always available to racers.
YAML lines: what the tire warmers can hide
# A warm run
config/initializers/countries.rb — 447.26ms
gems/faker-2.19.0/lib/locales/ja/address.yml — 182.118ms
gems/faker-2.19.0/lib/locales/es.yml — 2.831ms
# A cold rerun (`DISABLE_BOOTSNAP=1`)
config/initializers/countries.rb — 1369.053ms
gems/faker-2.19.0/lib/locales/ja/address.yml — 357.229ms
gems/faker-2.19.0/lib/locales/es.yml — 6.947ms
The require tree comes from hooking Ruby’s code loading, but not every boot cost is a require. Catching the rest is the gem plugin system’s job. YAML is the built-in plugin: every file parsed during boot gets its own line in the tree, no setup needed. Bootsnap can mask these lines, though: it caches the parsed result and serves it back on warm boots. So profile twice, with the cache and without (DISABLE_BOOTSNAP=1).
The block above is the same countries.rb line, warm and cold side by side. Warm, Bootsnap serves the already-parsed YAML from its cache, and the heaviest files still surface, since serving the cache isn’t free either. Cold, every file gets parsed for real, a second slower in total, and the true source arrives: faker’s Japanese address book is not what anyone ordered (unless you’re a big fan of The Fast and the Furious: Tokyo Drift).
Here’s what actually happened: faker appends every locale it ships to I18n.load_path, and we asked the countries gem to register a custom country before I18n was configured. That made I18n parse the entire load path: hundreds of files, all thrown away by the reloader before the first request.
I18n is the repeat offender behind most of this. Usually it hides in less exotic places though: model validations love to eagerly resolve translation messages at class-load time, preloading the entire locale set. So watch anything that touches I18n before the app is fully configured: class-level I18n.t calls in constants or validation messages (use the lazy block version), and locale-hungry gem setup in early initializers.
HTTP lines: off-track excursions
# A synthetic showcase
config/initializers/http_showcase.rb — 39.739ms
http:GET:https://www.ruby-lang.org/ — 11.574ms
# Another project
config/initializers/fragment.rb — 2064.081ms
http:POST:https://api.fragment.dev/graphql — 512.632ms
YAML isn’t the only plugin in the box: add the sniffer gem to the Gemfile, and HTTP requests show up as http:-prefixed lines. The first block is our planted evidence: http_showcase.rb exists only to show the line shape, since Factorial’s boot makes no network calls. In other projects, though, real ones get added and forgotten more often than expected.
Here’s how to hunt for them:
REQUIRE_PROFILE_FOCUS="http:" bundle exec ruby -r./config/boot -require-prof config/environment.rb
An HTTP call during boot sounds exotic until you go looking: at Whop, it was added by a third-party dependency (the second block in the snippet). Each one adds latency to every single boot and a fun new failure mode when the network hiccups mid-deploy.
Rails lines: the factory telemetry
rails:initializer:setup_main_autoloader:gems/railties-7.2.2.2/lib/rails/application/finisher.rb:18 — 1829.278ms
rails:initializer:run_prepare_callbacks:gems/railties-7.2.2.2/lib/rails/application/finisher.rb:73 — 4872.904ms
rails:to_prepare:gems/karafka-2.5.2/lib/karafka/railtie.rb:87 — 830.3ms
karafka.rb — 830.111ms
rails:initializer:finisher_hook:gems/railties-7.2.2.2/lib/rails/application/finisher.rb:93 — 2086.614ms
rails:load_hook:after_initialize:karafka.rb:20 — 705.965ms
The newest trick grew straight out of profiling monoliths like Factorial: the profiler now captures the Rails initialization pipeline as first-class rails: lines. initializer: covers railtie initializers (one-time setup steps that gems and the app register for boot), to_prepare: the reload callbacks (code that reruns on every code reload in development), load_hook: the lazy load hooks (deferred setup that fires when a framework part first loads, like ActiveSupport.on_load(:active_record)).
This closes the last thing the require tracking left open, because on a big app the requires are only half the story. At Factorial, the initialize! phase ate roughly two-thirds of the whole boot, and the run_prepare_callbacks line above claimed a fifth on its own.
The first culprit we found at Factorial is relevant to lots of Rails apps: Devise. By default, it forces all application routes to be reloaded, effectively loading them twice. The cure is one config line in config/initializers/devise.rb (try it on your next race too). On another routes-heavy app we audited earlier, this line alone once cut the boot from ~31 to ~23 seconds:
# When false, Devise will not attempt to reload routes on eager load.
# This can reduce the time taken to boot the app but if your application
# requires the Devise mappings to be loaded during boot time the application
# won't boot properly.
config.reload_routes = false
Another culprit was to_prepare hooks carrying crunch work that runs on every boot and every dev reload. Factorial leans heavily on centralized registry code catalogs, and two of these hooks constantized almost every app class into lists on each pass, feeding some skip logic down the line. Only rare code flows ever read the result. Both hooks became lazy: they resolve on first use and stay memoized until the relevant code actually changes.
Be careful with such changes, though: lazy work on a hot path can grow the tail latency. So always mind where a boot win lands: some fixes speed up development only (like the disabled global requires and the gem-group moves), to_prepare ones pay off on every reload, and some reach production boots too, for good or bad (like the I18n fixes).
Another rails: find at Factorial had consequences far beyond its own line: Karafka. Its railtie dragged the whole consumer graph into every process just to keep the topic topology on hand for producers (rarely needed in other process types). Along the way, it cost the app its lazy routes. Now Karafka loads only in its consumer processes, and the routes are lazy again for everyone else.
Back in the profile slice, Zeitwerk shows up too: the setup_main_autoloader line, weighing almost two seconds. Part of the cure was upstream: Zeitwerk 2.7.4 and up ship a validation optimization that pays off exactly at this size (and it’s not the first time a project this big has pushed the autoloader to its limits). The updating curse strikes again: bump the gem and collect the win.
And one of the deepest cuts hides in the same autoloader corner: config.add_autoload_paths_to_load_path = false, the default since Rails 7.1 (Zeitwerk never consults $LOAD_PATH anyway). Same story as before: options like this sit uncollected for years.
The final find again traces back to the monolith’s size: the structure sends one more bill. Each of the example app’s 200+ components is a Rails engine under the hood (with Packwerk guarding the boundaries), and sorting their initializers hit an O(n²) path in railties’ tsort usage, costing seconds on every boot. Rails 8.1 ships the fix. Until the upgrade hits, a guarded local patch holds the position.
The whole boot on one screen
Whoa, pump the brakes there! The project we’re speeding through carries over 200 components? Yes, Factorial, like lots of big codebases, takes the component/pack architecture to heart. This has its evident drawbacks (several slowdowns above trace back to the deep filesystem hierarchy) and its perks.
And one of the perks deserves a special showcase, but first we need to learn another, a more graphical way to read the report to understand it. The same profile exports as JSON and opens in speedscope’s neat UI:
REQUIRE_PROFILE_PATH=tmp/require-profile.json bundle exec ruby -r./config/boot -require-prof config/environment.rb
Three views are in the spotlight here. Time Order replays the boot left to right: the requires first, then the rails: phases, in the order they really ran. Unlike a sampled flamegraph, every millisecond on this axis is an exact load duration:

The whole boot on one screen: requires on the left, rails: phases on the right—and every millisecond on the axis actually happened
Left Heavy regroups the same data by weight: identical subtrees merge, and the widest offenders float to the left, regardless of how scattered their loads were in time:

run_prepare_callbacks is the heaviest phase of the boot
Sandwich ranks every frame by self time; the files that are slow on their own, not through their children (it’s also the simplest view to find the next cue):

The top 10 slowdown contenders
This is where the promised perk of the component structure finally shines. The profiler emits every path segment as its own frame, so costs stack by directory: components/#{name}/ blocks stand side by side on the screen, and every slow millisecond arrives with a code owner attached.
Yet there is still a blind spot worth noting. It can mark some line in the report as heavy, but never explain why. And some costs never get a line at all: for example, a bloated $LOAD_PATH taxes every require a little, everywhere at once. The answers hide below the require machinery, in Ruby internals, native code, and syscalls. Reading that layer takes a sampling profiler—the co-driver with the pace notes.
Lap 2: reading a sampling profiler without spinning out
So if you’ve ever needed to look inside the engine bay, down at Ruby internals—and let’s be honest, this is where many developers bail out and defer to the “mechanics”—here’s a short (extreme) driving course for you.
The sampling idea itself is simple: the profiler interrupts the program hundreds of times per second and records the current call stack. Time is attributed statistically; a method “taking N%” was on the stack in N% of samples. Nothing is traced exactly, which is why you should trust percentages over milliseconds on short boot runs. require-profiler integrates natively with Stackprof:
REQUIRE_PROFILE_STACKPROF=warden/cognito/test_helpers.rb bundle exec ruby -r./config/boot -require-prof config/environment.rb
That writes a speedscope-ready JSON next to the raw dump.
Reading the result takes one skill: telling total time (a method plus everything it calls) from self time (the work done in the method’s own body). A frame with huge total and near-zero self is a manager, not a worker. Drill into its children until self time shows up.
The views read the same on sampled dumps: Left Heavy turns thousands of samples into wide, readable bars, and Sandwich surfaces the native frames immediately, C code and syscalls included.
The first sampler find paid off quickly at Factorial: extra filesystem traversal, caused by deep directory nesting (components inside components), dragged the boot by over half a second. That’s pure Dir.glob work, syscalls with no tree line of their own. Fixed-depth globs instead of components/** walks cured it. An extreme case at this size, granted, but checking for excessive work wins again, this time via sampling.
And the second find was a brand-new catch. The require tree blamed a file in a local Warden fork—and that’s all we could say. The sampler showed the rest: the time was sinking into OpenSSL key generation, native code invisible to any require-level view:

OpenSSL key generation: 88% of the file’s load, all self time
It costs up to half a second when the machine runs low on entropy. The same C boundary hides the classic native extension init (grpc, sassc, etc.): one require kicks off the whole library setup, and Ruby sees none of it. C boundaries are where samplers pay for themselves.
So here’s the final plan. Run require-profiler first: plain requires, initializers, YAML, and HTTP, all in one readable pass. Drop to the sampler only for the lines the tree can’t explain.
With that, Factorial’s pit stop is over: the boot went from roughly 23 to 13 seconds, 40% off, with no obvious single hotspot left behind. Production got about 20% off too (several fixes were development-only), and every deploy now clocks faster.
Past that point you’re not removing bolts, you’re redesigning the car. One such possible redesign is scoped boot: each process type loads only its own components, so no web code in Sidekiq (another benefit of the component architecture). That’s a story for another legendary ride though.
One last ride: AnyCable’s own boot race
Remember anycable-rails on Bumbler’s shortlist at the very start? Framework gems end up on every leaderboard sooner or later, and nothing keeps a toolmaker honest like pointing the tool at their own gem. And yes, the surprise promised at the start finally shows up here.
So we profiled an AnyCable-powered app’s boot again, this time a bare demo. anycable-rails topped the leaderboard (what a win!) at 112ms, ahead of rails itself at 78ms, before a single cable was even connected.
require-profiler broke it down without drama. The first 10ms was dead weight: Anyway Config’s Doppler secrets loader required net/http at load time, pulling in the whole HTTP stack whether or not you use Doppler. Another 10ms came from testing-framework patching that lived in the production path. After our patches, it only kicks in when the Action Cable test classes are actually loaded. The gRPC stack added about 20ms more with nothing to trim inside.
The remaining chunk in the report was refinements, the biggest surprise of the race. Rewriting the gem’s refinement-based patching as plain class reopening saved 10ms more, which made no sense at first: refinements aren’t supposed to cost anything at definition time. A quick test said otherwise: activating them late in the boot, with ~30,000 classes around, is wildly more expensive than early, at ~4,000. An isolated benchmark confirmed it universally: on Ruby 3.3, using overhead grows with the class count, adding 50% or more at a real app’s scale. The cause is Ruby issue #21201: every refinement activation invalidates method caches through a full object-space scan. Ruby 4.0 fixed it with a dedicated refinement-cache table (unfortunately, no backports to older versions), and the same benchmark there shows the overhead more than ten times smaller.
What to do if you’re an AnyCable user? Upgrade Ruby regularly, to 4.0 and up in this case. Free optimizations help whether you know about them or not, and regular upgrades make each one easier. Stuck on an older Ruby? Then require refinement-heavy gems (anyway_config, anycable-rails) early in the Gemfile, right after rails/all (luckily, such gems are rare in the ecosystem).
After the fixes, anycable-rails ducked back behind Rails in the standings …the only race where the maintainer celebrates losing a position.
Handing the wheel to your AI robot family
Now we’ll go from tutorial to future infrastructure. require-profiler ships its own instructions for AI agents: a SKILL.md distributed via Rails Hyperdrive, the gem that lets any Ruby library deliver skills and guidelines straight into your agent’s context:
bundle add rails-hyperdrive require-profiler --group development
bin/rails hyperdrive:init
create .mcp.json
append .gitignore
append Gemfile
insert config/routes.rb
create .claude/skills/rails-boot-profiling/SKILL.md
create .hyperdrive/lock.yml
done hyperdrive initialized
The skill packs everything handy from this ride: the baseline measurement run, threshold-then-focus narrowing, the YAML and HTTP checks, the Stackprof handoff, and a checklist of the common offenders above. So the next time someone types “my app started booting slowly” the agent loads the skill as relevant, runs the profiler, and drives you to a quicker land in full self-driving mode (beware: please keep your hands on the steering wheel, it may do the wrong thing at the worst time, and the driver is responsible for all collisions with production).

Type ‘my app started booting slowly’, and magic happens: the skill loads, the profiler runs, and the boot time shrinks
The pit board, in five lines:
- Lap 0: keep Bootsnap enabled, updated, and precompiled for production. Remember it can mask YAML costs while you profile by serving pre-parsed caches, so add a cold rerun.
- Lap 1: run require-profiler first. The require tree with its plugins covers nearly every boot issue, and most fixes are one-liners. Consult the common offenders in the article.
- Lap 2: when the tree shows a file but not the reason, scope in with
REQUIRE_PROFILE_STACKPROF. Native code is where samplers pay off. - Final lap: profile third-party gems and frameworks too, especially the ones you maintain.
- Victory lap: install the skill via Rails Hyperdrive and let your agents help run the whole playbook.
Every Rails app race still starts the same, but now AI agents run it hundreds of times a day, and every boot is on the clock. Reach for require-profiler first (almost one-stop, easy to use), and save the sampling profiler for the rare corners the tree can’t explain. Teams that internalize profiling ship more than the ones still shopping for a bigger engine.
And somewhere at Le Mans, perhaps someone salutes.



