About

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.

Same login page, three setupscontroller JS the browser fetcheseagerLoadpreload: true (default)every controller (131 on that project)lazyLoadpreload: true (unchanged)still every controller, via modulepreload linkslazyLoadpreload: falseonly what this page usesjavascript_importmap_tags prints <link rel=modulepreload> for every pin with preload: true.The browser fetches each one, even if no JS ever imports it.

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

eagerLoadlazyLoadlazyLoad + preload: false
What it importsevery controller in the import maponly data-controller in the DOMonly data-controller in the DOM
modulepreload linksall (pin_all_from default)all (unchanged)application and stimulus-loading only
Controllers the login page getsallallonly what that page uses
Name ↔ filebuilds the name from the file namebuilds the file name from the name (- → _)same

Checklist before switching

CheckHow
preload: falseadded to pin_all_from in config/importmap.rb?
Hyphenated file namesfind app/javascript/controllers -name '*-*_controller.js' returns nothing?
Call orderlazyLoadControllersFrom runs after any manual application.register?
Quiet failuresno Failed to autoload controller in the console on a tab left open across a deploy?
First clickcleared the cache and tried the button right after a modal opens, and repeat-click buttons?
Cache, HTTP/2long Cache-Control on digested files, HTTP/2 or later?
E2Eif none, listed the screens you clicked through in the PR?

Key Points

1

In controllers/index.js, change eagerLoadControllersFrom to lazyLoadControllersFrom

2

Add preload: false to pin_all_from "app/javascript/controllers" in config/importmap.rb

3

Find controllers with hyphens in the file name and switch them to underscores

4

Move lazyLoadControllersFrom after any manual application.register

5

Clear the cache and click through modals, Turbo Frames and repeat-click buttons by hand

6

In the page source, confirm only application and stimulus-loading are left as modulepreload links

Use Cases

When signed-out pages like login or a landing page download every controller When Puma also serves static files and asset requests eat its slots When one controller stops attaching after switching to lazy
← Why a password_digest Column Breaks Devise's inspect Faraday open_timeout: Leave It Unset and It Takes Your timeout →