Engineering

Does obfuscation break code splitting and dynamic imports?

No — and the reason it looks like it does is almost always one of three concrete mistakes, none of which is really about the transforms. A split build is just a directory of files that reference each other by filename and, in a few places, by string. Protection is safe wherever those two things are left alone, and it fails wherever they are not. Here is each failure, what it looks like in the browser, and the fix.

Failure 1: protecting chunks in separate passes

This is the big one, and it is the one that produces the most baffling symptom: the app loads, you navigate to a lazy route, and you get TypeError: t.getQuote is not a function from a module that worked perfectly before protection.

Cross-file transforms make their decisions per run. When Protect Members renames getQuote to m3, that mapping holds for everything in the same protection pass. Run your entry chunk and your lazy chunk through two separate passes and each one gets its own naming decisions — the entry chunk calls m3, the lazy chunk defines m7, and nothing in either file is individually wrong. The same applies to Replace Globals, and to any shared string table.

The fix is to point the protector at the output directory, not at files one at a time:

npm run build
npx jso-protector --preset maximum \
  --input dist --output dist-protected

One pass, one set of decisions, all chunks agree. If your pipeline loops over files with find … -exec, that loop is the bug. This is also why a per-file webpack loader is the wrong shape for cross-file protection: a loader sees one module at a time and cannot make a project-wide rename that stays consistent.

Failure 2: changing chunk filenames or layout

Every bundler emits a small runtime that maps a chunk id to a URL — often something like "static/js/" + id + "." + hashes[id] + ".chunk.js". That mapping is data, computed at build time and baked into the entry chunk. Protection has no idea it exists.

So the rule is: protect file contents, preserve file names and directory structure exactly. Do not flatten dist/assets/ into dist/, do not append .min, do not re-hash filenames after protection. If you do, the entry chunk asks for a URL that no longer exists and you get a 404 followed by a rejected import() — typically surfacing as ChunkLoadError or Failed to fetch dynamically imported module.

Two related traps live here. Anything that pins content — a <link rel="modulepreload">, a service worker precache manifest, an asset manifest consumed by your server template, or a Subresource Integrity hash — was generated against the pre-protection bytes. Regenerate all of it from the protected output, or generate it after protection in the first place.

Failure 3: strings the loader still needs to read

Most literals in a bundle are inert data that only your own code reads, which is why moving them into a table is safe. A handful are different: they are read by something that is not your code.

  • Public path and asset base. Frameworks resolve chunk URLs against a base string, sometimes assigned to a global the runtime reads by name. If a rename or a rewrite disconnects the two, every lazy chunk resolves against the wrong origin.
  • Module specifiers in native ESM output. If you ship unbundled modules rather than a bundle, import('./checkout.js') is a live URL the browser resolves. Keep specifiers literal — the general treatment is in obfuscating ES modules.
  • Worker and wasm URLs. new Worker(new URL('./worker.js', import.meta.url)) is a build-time pattern most bundlers rewrite. Treat the result as a filename reference, not a string to transform — and see does obfuscation break web workers.
  • Chunk name comments. import(/* webpackChunkName: "admin" */ './admin') is consumed by the bundler before protection ever runs, so it is not a protection concern — but it does mean the chunk name it produced is now a filename, which loops back to failure 2.

The clean way to handle all of these is ordering, not configuration: protect after bundling. By that point the bundler has already resolved its own specifiers into its own runtime calls, and the only strings left are the ones listed above. Reserve those explicitly using exclusion rules.

What splitting costs you, and what it buys

Two effects are worth knowing before you turn protection on across a heavily split build.

Per-chunk overhead is real. Each protected file carries its own decoder and its own string table. Across five chunks that is roughly five copies of the fixed cost, and on small lazy chunks the relative growth looks alarming even though the absolute number is modest. The compressed picture is much gentler than the raw one — the measurements are in how much bigger does obfuscation make your bundle.

Splitting is also an opportunity. A split build has already sorted your code into units by how it loads, which is exactly the sorting protection wants. The chunk containing your licensing and entitlement logic loads once and can afford VM protection. The chunk containing your virtualised table renderer runs continuously and should get renaming and string protection only. Uniform settings across every chunk waste budget in one place and leave value on the table in the other.

A smoke test that catches all three

Automated checks tend to load only the entry chunk, which is exactly the code path that cannot fail. Test the lazy ones deliberately:

  • Cold-load the app, then navigate to a route that is code-split. Watch the network panel for a 404 and the console for ChunkLoadError.
  • Trigger a component that is imported on demand — a modal, an editor, a chart — and confirm it renders rather than throwing.
  • Exercise a path where a lazy chunk calls into a shared module. This is the one that catches inconsistent cross-file renaming.
  • Hard-refresh on the lazy route so it loads as an entry point rather than via client navigation.
  • Diff the file list before and after protection. It should be identical: same names, same paths, same count.

Use a seeded build while you are debugging. Reproducible output means the failure you are chasing does not move between runs, which turns an intermittent-looking problem back into an ordinary one.

The short version

Code splitting and obfuscation are compatible. Protect the whole output directory in a single pass so cross-file renames agree; keep filenames and folder structure byte-identical so the bundler runtime can still find its chunks; regenerate anything that hashes or lists those files; and reserve the few strings that something other than your own code reads. Do those four things and lazy loading behaves exactly as it did before protection — if it still does not, work through the six causes of broken protected output in order.

Related reading