Why Stimulus lazyLoad Still Downloads Every Controller
importmap's pin_all_from defaults to preload: true. Change preload: false along with the eager-to-lazy line, then check names, call order and the first click
This is for anyone running Stimulus on importmap in a Rails app and about to swap eagerLoadControllersFrom for lazyLoadControllersFrom. By the end you'll know why that one line doesn't cut the downloads on its own, and you'll have six things to check before you ship it.
Short version: pin_all_from defaults to preload: true. Even after you switch to lazy, every controller is still printed into the HTML as <link rel="modulepreload">, and the browser fetches all of them. You also need preload: false in config/importmap.rb.
The login page was pulling 131 controllers
On a work project (a contract management SaaS), right after a bulk email went out, the ALB returned tens of thousands of 502s. Why it died at 21% CPU is in Puma Slot Exhaustion: Why CPU Sat at 21% During 60,000 502s. This post is about what filled those slots.
I counted about 690,000 lines of ALB access logs by path.
/renewals the link in the email ~8%
/login not signed in, 302 → login ~4%
/assets/** JS modules ~88%
A login page, downloading the controllers for the org admin and accounting screens. Puma served static files itself (public_file_server), so each of those files took a Puma slot.
The culprit was one line in app/javascript/controllers/index.js, exactly as the stimulus-rails installer writes it.
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
What eager and lazy actually do
Open stimulus-loading.js in stimulus-rails 1.3.4 and both are short.
eagerLoadControllersFrom takes every importmap key ending in controllers/…_controller and calls import() on it. It doesn't matter if the page has zero data-controller attributes.
lazyLoadControllersFrom imports only the data-controller values currently in the DOM, then watches for new ones with a MutationObserver (the browser API that tells you when the DOM changes).
At this point it looks like a one-line fix. That's what I thought too.
One line didn't do it — modulepreload
javascript_importmap_tags does more than print the import map. For every pin with preload on, it prints a <link rel="modulepreload">. The browser sees that and fetches the module ahead of time, whether or not anything imports it.
And pin_all_from defaults to preload: true. On importmap-rails 2.2.2 I pinned a folder with three controllers via pin_all_from and dumped the preload list.
preload: true (default)
modulepreload: application.js, stimulus-loading.js,
channel_controller.js, file-dropzone_controller.js, hello_controller.js
preload: false
modulepreload: application.js, stimulus-loading.js
importmap keys: all 3 controllers still there
Switch to lazy and leave this alone, and the HTML is still telling the browser to grab everything even though the JS never imports it. So there are two places to change.
# config/importmap.rb
pin_all_from "app/javascript/controllers", under: "controllers", preload: false
With preload: false the importmap keys stay. The address lazy imports from is still there, so behavior is the same. Only the prefetch goes away.
Check 1 — does the name match the file name exactly
Eager builds the name from the file name: _ becomes -, / becomes --. Lazy goes the other way and builds the file name from the name: -- becomes /, - becomes _.
They're inverses, so some files only work in one direction. A file with a hyphen in its name, like file-dropzone_controller.js, is one. I ran both functions as-is in node.
file-dropzone_controller
name eager registers file-dropzone → works
file lazy looks for file_dropzone_controller → not found
A controller that happened to work under eager silently stops attaching under lazy. Find these first.
find app/javascript/controllers -name '*-*_controller.js'
2 — failures are quiet
When lazy's import() fails, all you get is one console.error("Failed to autoload controller: …"). The page renders fine. That one button just does nothing.
Tabs left open across a deploy are the risky case. The import map baked into the page points at the old digest (the content hash in the file name). If the new image doesn't have the old file, opening the modal is a 404. The importmap tag carries data-turbo-track="reload", so a Turbo visit to another page reloads, but opening a modal on the same page never gets that chance.
3 — call it after any manual register
If some code registers controllers by hand, like application.register("dropzone", …), call lazyLoadControllersFrom after it.
stimulus-loading skips names that are already registered (canRegisterController). But if lazy runs first, the name isn't registered yet at that moment, so a fetch goes out. The module gets filtered again at register time and thrown away. One wasted request.
4 — elements added later are fine
This was my biggest worry when I first wrote the issue up: Turbo Frame swaps, Turbo Stream appends, modals inserted into the DOM, and JS that does setAttribute("data-controller", …).
The source shows the MutationObserver watching childList and subtree, plus attributeFilter: ["data-controller"] for attribute changes. All four are covered. The mechanism being right and the actual screens working are two different things, though, so I still clicked through each of those screens by hand.
5 — the first click can get eaten
With lazy, after an element appears it goes import → register → connect(). data-action is wired up in connect(). A click before that does nothing.
With a warm cache that's a few ms and you won't notice. With a cold cache it's a network round trip. Clear the cache and try the button you press the moment a modal opens, and anything people click repeatedly.
6 — requests drop to "what this page uses", not to zero
Lazy doesn't remove requests. A page with 20 controllers makes 20 requests. So check two things before switching: are you on HTTP/2 or later, and do digested files get a long Cache-Control.
# config/environments/production.rb
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=31536000, immutable"
}
In this incident, the people clicking the email link and the scanners all arrived with fresh sessions, so the cache did nothing. That's how it got to 88%. The real fix is having a CDN or a front proxy serve static files instead of Puma. Lazy shrinks the volume until then.
That project also had zero spec/system tests. Checks 2, 4 and 5 are the kind you only learn by clicking, so switching without E2E means you're also deciding to cover it with manual QA. Write down in the PR which screens you clicked through.
With a bundler (Vite, esbuild) it's a different conversation
On importmap, one file is one request, so eager vs. lazy is literally the request count. With a bundler, eager puts everything in one bundle: one request, and the problem becomes size. Lazy becomes code-split chunks. Vite's import.meta.glob is lazy by default (it returns functions), and { eager: true } makes it static imports.
Here's how I decide. A few dozen controllers or fewer, used similarly across pages: leave eager. Controllers that differ a lot by page, plus signed-out pages like login or a landing page that take real traffic: lazy plus preload: false.
If you want someone to look at where an older Rails app's frontend assets go and how much, all the way to getting static files off Puma, you can ask negabaro.
eager vs. lazy vs. preload — what the login page downloads
| eagerLoad | lazyLoad | lazyLoad + preload: false | |
|---|---|---|---|
| What it imports | every controller in the import map | only data-controller in the DOM | only data-controller in the DOM |
| modulepreload links | all (pin_all_from default) | all (unchanged) | application and stimulus-loading only |
| Controllers the login page gets | all | all | only what that page uses |
| Name ↔ file | builds the name from the file name | builds the file name from the name (- → _) | same |
Checklist before switching
| Check | How |
|---|---|
| preload: false | added to pin_all_from in config/importmap.rb? |
| Hyphenated file names | find app/javascript/controllers -name '*-*_controller.js' returns nothing? |
| Call order | lazyLoadControllersFrom runs after any manual application.register? |
| Quiet failures | no Failed to autoload controller in the console on a tab left open across a deploy? |
| First click | cleared the cache and tried the button right after a modal opens, and repeat-click buttons? |
| Cache, HTTP/2 | long Cache-Control on digested files, HTTP/2 or later? |
| E2E | if none, listed the screens you clicked through in the PR? |
Key Points
In controllers/index.js, change eagerLoadControllersFrom to lazyLoadControllersFrom
Add preload: false to pin_all_from "app/javascript/controllers" in config/importmap.rb
Find controllers with hyphens in the file name and switch them to underscores
Move lazyLoadControllersFrom after any manual application.register
Clear the cache and click through modals, Turbo Frames and repeat-click buttons by hand
In the page source, confirm only application and stimulus-loading are left as modulepreload links