Skip to content

ESM output bakes hashed chunk filenames into importing modules, defeating long-term caching (no opt-out) #22056

Open
webpack/webpack.js.org
#8387

Description

@Anonym-tsk

Bug report

What is the current behavior?

Since 5.110, ESM output bakes a literal import("<chunk file>") next to the chunk id at every dynamic import site (RuntimeTemplate.analyzableChunkImport, __webpack_require__.ei):

// 5.104.1 — chunk id only; the runtime resolves the filename from its map
n.e(4458, "high")

// 5.110.3 — the hashed filename is baked into the importing module
s.ei(4458, () => import("/static/js/ton.2413d995a4.js"))

Because the filename carries [contenthash], the importing module's own content now depends on the imported chunk's content, and that dependency propagates up the import graph into the initial wave. The chunk-id → filename map still exists in the runtime chunk, so the hashes are now stored twice.

This defeats long-term caching. optimization.runtimeChunk: 'single' exists precisely so that chunk filenames live in one small chunk and a change to a lazily loaded route invalidates only that chunk plus the runtime.

Measurement on a real application (~1850 chunks, ESM output, runtimeChunk: 'single', [contenthash] filenames). I changed one attribute in a single component that is only reachable through React.lazy, and rebuilt:

5.104.1 5.110.3
initial chunks invalidated 4 of 9 5 of 9
extra chunk invalidated — app.js, 1.07 MB raw / 197 KiB brotli
initial bytes a returning user refetches 37 KiB gz (6%) 288 KiB gz (46%)

The diff of that 1.07 MB app.js between the two builds is exactly 10 bytes — the content hash of the lazily loaded chunk inside one baked import("/static/js/39137.<hash>.js"). Nothing else changed. Ten bytes of real change cost the user a 197 KiB brotli refetch.

There is a second, smaller cost: the baked URLs add ~195 KiB of raw JS to the initial wave (2 057 416 → 2 257 607 bytes). Compression hides most of it (+3.8 KiB brotli, −12.6 KiB gzip), so this one is minor, but it is still parsed on the main thread.

If the current behavior is a bug, please provide the steps to reproduce.

  1. output.module: true, optimization.runtimeChunk: 'single', output.chunkFilename containing [contenthash].
  2. An entry chunk that holds modules doing import() of lazily loaded routes.
  3. Build, change one module that lives only in a lazily loaded chunk, rebuild.
  4. On 5.109 and earlier only the runtime chunk changes; on 5.110+ the entry chunk holding the importing modules changes too.

What is the expected behavior?

An opt-out. Baked specifiers are genuinely useful — a browser preload scanner or another bundler can follow them — so I am not asking to revert the behaviour, only to be able to choose. Something like output.analyzableChunkImport: false, or a value on the existing output.resourceHints group, would be enough.

There appears to be no way to turn it off today: supportsAnalyzable consults only output.module, build-time execution, and whether __webpack_public_path__ is reassigned; nothing in the schema, defaults.js or the docs mentions it. The output.chunkFilename documentation still describes the pre-5.110 behaviour ("these filenames need to be generated at runtime … need to add a mapping from chunk id to placeholder value to the output bundle with the webpack runtime").

As a workaround I patch analyzableChunkImport to return null, which is the method's own documented fallback to the runtime ensureChunk form. Measured, it restores the old profile exactly — 4 of 9 initial chunks and 6% refetched, app.js untouched — and the initial wave ends up smaller than on either version, because the 5.109/5.110 CommonJS tree-shaking gains are real and independent of the baking:

initial wave raw gzip
5.104.1 2 057 416 651 517
5.110.3 2 257 607 638 654
5.110.3 + workaround 2 044 649 617 101

Other relevant information:

webpack version: 5.110.3 (5.104.1 for the comparison)
Node.js version: 25.9.0
Operating System: macOS 15 (arm64)
Additional tools: yarn 4.12.0, babel-loader, output.module: true, optimization.realContentHash on (production default)

Related: #22026 (also from this codebase).

Activity

  1. added
    area: configOptions, defaults, validation (lib/config, schemas/)
    area: runtimeEmitted runtime and chunk loading across targets (lib/runtime, lib/web, lib/node, lib/esm)
    regressionWorked in a previous release; pair with the Affected version field
    on Sep 11, 2026
  2. Pcmhacker-piro commented on Sep 11, 2026

    @Pcmhacker-piro

    Can confirm this behavior on webpack 5.110.3 with a minimal test setup:

    Minimal Reproduction & Measurement

    // webpack.config.js
    module.exports = {
      mode: "production",
      entry: { main: "./src/main.js" },
      output: {
        filename: "[name].[contenthash].js",
        chunkFilename: "[name].[contenthash].js",
        module: true,
        clean: true
      },
      experiments: { outputModule: true },
      optimization: { runtimeChunk: "single", minimize: false }
    };
    
    // src/main.js
    import("./lazy.js").then((m) => m.run());
    
    // src/lazy.js
    export function run() { console.log("v1"); }
    1. Initial Build:

      dist/613.76648de93a11b6c15009.js  (lazy chunk)
      dist/main.7a84a4b2ae8792625aab.js (entry chunk)
      dist/runtime.5fa7d5780e8ec7bfbad2.js (runtime chunk)
      

      Inspecting main.7a84a4b2ae8792625aab.js confirms the baked specifier:

      __webpack_require__.ei(613, () => (import("./613.76648de93a11b6c15009.js"))).then(() => (__webpack_require__(613))).then((m) => m.run());
    2. Change ONLY lazy.js ("v1" -> "v2"):

      dist/613.0e67c174c336021c2740.js   (changed, expected)
      dist/runtime.5fa7d5780e8ec7bfbad2.js (unchanged)
      dist/main.891cc514ab2bc628ab23.js  (hash changed from 7a84... to 891c...)
      

    Because __webpack_require__.ei bakes the literal filename ./613.<contenthash>.js into main.js, any change in an asynchronous leaf chunk cascades into parent importing modules and breaks long-term caching of the main application bundle.

    An opt-out (e.g. output.analyzableChunkImport: false) to fall back to the runtime chunk map resolution would restore expected caching semantics for users relying on optimization.runtimeChunk: 'single'.

  3. Pcmhacker-piro commented on Sep 11, 2026

    @Pcmhacker-piro

    I have submitted a pull request addressing this: #22062

    It adds output.analyzableChunkImport (defaults to true), allowing users to opt out with output.analyzableChunkImport: false. When disabled, dynamic imports fall back to runtime chunk resolution (__webpack_require__.e), preventing chunk content hashes from being baked into importing modules and preserving long-term caching.

  4. added a commit that references this issue on Sep 12, 2026
    c256cbb
  5. added a commit that references this issue on Sep 13, 2026
    0f7ee78
  6. Anonym-tsk commented on Sep 14, 2026

    @Anonym-tsk
    Author

    Thanks for the fix — moving the specifiers into the chunk loader is a better answer than the opt-out I asked for, since it keeps the output followable and still keeps chunk hashes out of importing modules.

    #22076 says "Closes #22056" and shipped in 5.111.0. This issue was closed and reopened twice on 13 Sep and is open again now, still labelled needs: reproduction. Is something outstanding that the PR did not cover, or is it just left open by accident? Happy to close it myself if it is the latter.

  7. alexander-akait commented on Sep 14, 2026

    @alexander-akait
    Member

    @Anonym-tsk Hello, thanks for the feedback, long-term was solved, but as you can see the raw size is bigger (gzip is less) than before, we want to focus on it and reduce it as much as possible

  8. Anonym-tsk commented on Sep 14, 2026

    @Anonym-tsk
    Author

    Confirmed on our application — thank you for turning this around so quickly, and for solving it properly rather than with the opt-out I originally asked for.

    The cascade is gone. Changing one attribute in a component reachable only through a lazy route now leaves every other initial chunk untouched, exactly as before 5.110, and no importing module carries a chunk hash any more.

    One data point that may be useful, since our graph is much larger than the repro in the PR. We emit around 1840 JavaScript chunks, so the specifier map in the chunk loader is 1838 entries, and it moves the runtime chunk by a good deal more than the +99 gzip bytes measured on the 10-chunk shape:

    runtime chunk raw gzip
    5.110 68 444 32 123
    5.111.0 169 478 59 647

    Since the runtime rehashes on every release, that lands on every returning user: of the 64.2 KiB gzip our initial wave refetches after a release, the runtime is now 60.0 KiB, or 91% of it.

    None of this takes away from the fix — 10% of the initial wave refetched is far better than the 46% we had on 5.110, and the correctness problem is gone. I only wanted to note that the map scales with the chunk count, in case it is worth emitting lazily or splitting it per chunk for graphs this size. Happy to measure anything else on our shape if that would help.

    Also noticed that 5.111.0 drops the err.message fallback in RuntimeModule.updateHash. That was the root cause behind #22026, which took our production down, so both halves of that story are now fixed upstream. Much appreciated.

  9. codeXsidd commented on Oct 22, 2026

    @codeXsidd
    Contributor

    /claim

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: configOptions, defaults, validation (lib/config, schemas/)area: runtimeEmitted runtime and chunk loading across targets (lib/runtime, lib/web, lib/node, lib/esm)needs: reproductionNo runnable minimal reproduction yetregressionWorked in a previous release; pair with the Affected version field

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions