Engineering
Published
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.
Frequently asked questions
Does obfuscation break lazy-loaded chunks?
Not by itself. Dynamically imported chunks survive protection when you protect the whole output directory in a single pass, leave the chunk filenames alone, and keep the strings the bundler runtime needs to read intact. Every failure in this area traces back to one of those three, which means each of them is avoidable by configuration rather than by giving up splitting.
Why does protecting chunks in separate passes break the build?
Because renaming is only consistent within a single pass. Chunks share references with each other and with the entry bundle, and a separate pass gives the same underlying name a different result in each file, so the references stop lining up. Point the protection step at the whole output directory in one run so every file is renamed against the same map.
Can I change chunk filenames as part of protecting the output?
No. The bundler writes a runtime that resolves chunk names at load time, and those names are a contract between the entry bundle and the files on disk. Renaming an output file, changing its extension or moving it into a different directory breaks the lookup and produces a failed network request at the moment the user triggers the lazy route.
Which strings does the bundler runtime still need to read?
The ones that participate in chunk resolution: chunk identifiers, the public path, and any map from a module identifier to a filename. Transforms that move or encrypt literals can reach those values if they are applied indiscriminately, and the result is a loader that requests a nonexistent URL. Exclude the runtime chunk or the identifiers it depends on rather than weakening the transform everywhere.
Is code splitting worth keeping once the output is protected?
Usually yes. Splitting exists to keep the initial download small, and protection adds size, so the two interact in your favour rather than against you. What splitting costs you is a slightly larger surface for the three failures above, all of which are caught by a smoke test that exercises each lazy route once.
How do I check that splitting still works after protection?
Load the protected build and navigate to every route or feature that triggers a dynamic import, watching the network panel for failed chunk requests and the console for module resolution errors. This is the one test that catches all three failure modes, it takes minutes, and it belongs in your release checklist because a broken lazy route is invisible on the landing page.
Related reading