Build & Delivery

Obfuscation and long-term caching: why every build busts your cache

You add protection to a pipeline that was carefully tuned for caching. Immutable filenames, a year-long max-age, a CDN that almost never goes to origin. Then you ship a one-line copy change and your monitoring shows every returning visitor downloading every chunk again, and the CDN refilling from scratch. Nothing is broken. The two designs are simply working against each other, and it is worth understanding why before reaching for the fix.

Two systems with opposite goals

Long-term caching rests on one assumption: identical input produces an identical file. That is what makes app.4f3c9a1b.js meaningful. The hash is a promise that this URL will never hold different bytes, which is what licenses Cache-Control: max-age=31536000, immutable. A build that changes nothing must emit the same hash, or the whole scheme degrades into re-downloading everything on every deploy.

Polymorphic obfuscation rests on the opposite assumption: identical input should produce a different file. Identifier choices, string-table ordering, the shape of the emitted decoder — all vary per run, deliberately. Two customers on the same release do not share a byte signature, an attacker's notes against one build do not transfer to the next, and a tool that recognises last month's decoder does not recognise this month's.

Both are correct. They just cannot both be true of the same artifact at the same time, so you have to choose per release — which turns out to be exactly the right granularity.

What it costs before you fix it

The damage is wider than a slower first paint:

  • Every returning visitor is a cold visitor. Every hashed chunk gets a new name each deploy, so the browser cache holds nothing usable. Vendor chunks — deliberately split out because they change rarely — are the biggest and are re-downloaded along with everything else.
  • Your CDN refills from origin on every release. New URLs are cache misses by definition. If you pay for egress, this is a line item.
  • Release diffing stops working. Diffing consecutive builds is how you catch an unintended dependency bump or a secret that got bundled. When every byte moves, the diff is noise and nobody reads it.
  • Any pinned integrity hash breaks. An SRI attribute is a hash of the exact bytes served. A rebuild that changes bytes invalidates it, and a mismatched SRI hash is not a warning — the browser refuses to execute the file.
  • Build caches miss. Any layer that caches by content — a monorepo task runner, a Docker layer, a CI artifact cache — sees a changed artifact and redoes the work behind it.

The direct fix, and why "pin it once" is wrong

The engine takes a seed. With the same input, the same options and the same seed, the output is byte-identical. It is the seed field in jso.config.json, --seed on the command line, or the Seed API option — the reproducible builds page covers the mechanics.

The tempting move is to write "seed": 12345 and forget it. That does fix caching, and it silently discards the property you were paying for. With a permanently fixed seed, an attacker holding two of your releases can diff them and see only your real changes — the obfuscation noise cancels out perfectly, which is the one thing polymorphism exists to prevent. You have converted a protected bundle into a readable changelog.

Derive the seed from the release instead:

// jso.config.cjs
module.exports = {
  input: "dist",
  output: "dist-protected",
  preset: "balanced",
  // Same release => same bytes. New release => everything moves.
  seed: process.env.RELEASE_TAG || require("./package.json").version,
  manifest: "dist-protected/jso-manifest.json"
};

Now a rebuild of v2.4.1 — a retried CI job, a rollback, a reproducibility check by a customer's security team — produces the identical artifact, so hashes hold and caches stay warm for the whole life of that release. Ship v2.4.2 and every identifier moves, so cross-release diffing gains nothing. You get caching within a release and polymorphism between releases, which is what you actually wanted.

Two practical notes. The seed is an input to your build, not a secret that protects anything, but it is still worth keeping out of shared logs — the CLI's own migration guidance says the same. And a version string is fine as a seed: any non-integer value folds to a stable internal value, so you are not restricted to numbers.

The ordering bug that survives the seed fix

There is a second, quieter problem, and a seed does not touch it. Consider the common post-build invocation:

npm run build                      # emits dist/app.4f3c9a1b.js
npx jso-protector --input dist --output dist-protected

The bundler computed 4f3c9a1b from the unprotected bytes. Protection then rewrites the contents and keeps the name. The file you serve is now called app.4f3c9a1b.js while containing something that hashes to nothing of the kind. It works — the browser does not verify filenames — but the hash no longer means what your infrastructure believes it means. Two releases whose pre-protection output happened to be identical will share a filename while shipping different bytes, and a client holding the old one under immutable will never fetch the new one.

The fix is to protect inside the build so hashing sees the final bytes. The bundler plugins are built for this: the webpack plugin processes assets at webpack's size-optimization stage, which runs before its hash-optimization stage, so with optimization.realContentHash (on by default for production builds) the emitted filename is computed over protected output. The Vite and Rollup plugin protects chunk code during bundle generation, for the same reason. Whichever you use, verify rather than assume — change one protection option, rebuild, and check whether the emitted filenames moved. If they did, hashing is downstream of protection and you are fine. If they did not, hashing is upstream and you need to recompute names yourself.

When you do need to recompute, the manifest already has the numbers. Every run written with --manifest records sourceSha256, outputSha256, and byte counts per file — the same values to generate SRI attributes from, and the reason reproducible builds and integrity pinning end up being the same conversation.

Cache what does not need protecting

The cheapest win is not a configuration option. Protection costs cache stability, so do not spend it on code that carries no value: a charting library, a date utility, a polyfill bundle. That code is public, identical across thousands of sites, and worth nothing to an attacker who can npm install it.

Split vendor dependencies into their own chunk and exclude it. Some of the bundler presets — Bun and Parcel — already ship **/vendor/** in their default exclude list alongside source maps, which is a hint about intent; set the same exclusion explicitly wherever else you configure protection. Your vendor chunk — usually the largest single asset — then keeps a stable hash across every release where you did not change dependencies, and stays in the browser cache and the CDN for months. Protection concentrates on your own chunks, where it is doing something.

The same reasoning applies to size budgets. --max-output-bytes and --max-growth-ratio fail a build whose protected output crosses a threshold, which is worth setting deliberately: growth is paid for on every cold download, and every polymorphic rebuild makes more downloads cold than you expected.

A checklist

  • Seed from the release identifier, not a constant, and not nothing.
  • Confirm your content hashes are computed over protected bytes — test it by changing a protection option and watching the filenames.
  • Exclude third-party vendor chunks and let them cache across releases.
  • Generate SRI from outputSha256 in the manifest, in the same job that produced the file.
  • Keep immutable only on filenames that genuinely change when the bytes change.
  • Watch cold-download volume for one deploy after switching. It is the metric that tells you whether the ordering is right.

Frequently asked questions

Why does my content hash change when nothing changed?

Protected output is polymorphic by default: identifier choices, string-table order and the emitted decoder differ on every run so that no two builds share a signature. Different bytes produce a different content hash, which produces a different filename, even when the source is untouched.

How do I make obfuscated output byte-identical between builds?

Set a seed. With the same input, the same options and the same seed, the engine produces byte-identical output. It is available as the seed field in jso.config.json, the --seed flag on the CLI, and the Seed API option.

Does fixing the seed weaken protection?

It removes build-to-build variety, which is a real property: with a pinned seed an attacker can diff two releases and see only your genuine changes. Rotate the seed per release - deriving it from the version or release tag - so rebuilds of one release are identical while consecutive releases still differ.

Should I obfuscate before or after the bundler computes hashes?

Before. If you protect the output directory as a post-build step, the filenames were already derived from the pre-protection bytes and no longer describe the file being served. Use the bundler plugin so protection happens inside the build, or recompute the hashes yourself afterwards.

Does this affect Subresource Integrity?

Yes, in the same way. An SRI hash is a hash of the exact bytes served, so a polymorphic rebuild invalidates any integrity attribute pinned against a previous build. The protection manifest records a SHA-256 per output file, which is the value to generate SRI from.

Related reading