VEILVMEvery form of JavaScript obfuscation costs something at runtime. Identifier renaming is nearly free....
Every form of JavaScript obfuscation costs something at runtime. Identifier renaming is nearly free. String encoding adds lookups. Control-flow flattening and especially VM-based protection can turn a cheap function into an expensive one. Teams that skip measurement either ship a sluggish app or, more often, panic-disable protection after a bad launch.
This article is a practical method for measuring that cost before release, deciding what to protect, and keeping the hot path clean.
Not all transforms are equal.
| Transform | Typical runtime cost | Notes |
|---|---|---|
| Identifier renaming / minify | Negligible | Do it everywhere |
| String array / encoding | Low | Extra lookups and decoding |
| Control-flow flattening | Moderate | Opaque predicates, extra branches |
| Dead-code injection | Size + some CPU | Mostly hurts download and parse |
| VM / bytecode interpreter | High | Dispatch loop per operation |
Minification belongs on every production build. The expensive layers belong on a short list of functions — and only after you have measured them.
The biggest performance mistake is running a heavy transform over an entire chunk. A scoring function that runs once at the end of a level can absorb a VM. A function called inside requestAnimationFrame cannot.
Start by listing the sensitive work you actually need to hide:
Then ask, for each one: how often does it run, and on what budget? Anything that runs per frame, per keystroke or per scroll event is a poor candidate for heavy protection. Move it off the hot path if you can (debounce, run once, cache the result), or protect it lightly and keep the strong transforms for cold code.
You need three builds of the same app, not one:
Keep everything else identical: same machine, same browser, same dataset, same warm-up. Measure with the browser's own tools, not Date.now() sprinkled in one place.
Isolate each candidate:
// run in a dedicated page that imports the built modules
const N = 10_000;
const input = makeFixture();
performance.mark("fn-start");
for (let i = 0; i < N; i++) candidate(input);
performance.mark("fn-end");
performance.measure("fn", "fn-start", "fn-end");
const m = performance.getEntriesByName("fn").at(-1);
console.log(`${m.duration.toFixed(2)} ms / ${N} calls`);
Run baseline and protected builds through the same harness. Record median of five runs after a warm-up pass. A license check that goes from 0.05 ms to 2 ms is usually fine if it runs once at startup. A function that goes from 0.05 ms to 2 ms inside a 16 ms frame budget is not.
Microbenchmarks miss allocation pressure and interaction with the rest of the app. Record a Performance profile in Chrome:
Also watch CLS and INP (or FID on older reports) in Lighthouse or your RUM tool if the protected code sits near first input. Bundle size matters too — heavy obfuscation increases parse and compile time on mid-range phones.
# after each build
du -b dist/assets/*.js | sort -n
npx source-map-explorer dist/assets/app.*.js # only if you kept a private map
A 30% size jump on a cold chunk may be acceptable. The same jump on the main bundle that blocks first paint is not. Prefer putting protected modules in their own async chunk so the cost is paid when the feature is used, not on every page load.
// load the protected unlock module only when needed
const { unlock } = await import("./unlock.protected.js");
Write the numbers down before you ship, not after a complaint:
If a candidate blows the budget, you have three options: protect it more lightly, call it less often, or move the work to the server. Do not "just ship it" and hope devices are fast enough.
Suppose you have three functions:
verifyLicense() — once at startup, currently 0.2 ms. After VM protection: 3 ms. Accept. Startup can spare 3 ms.signScore(payload) — once per submission, 0.1 ms → 4 ms. Accept. Rare, not user-visible as jank.applyDiscount(cart) — called on every cart edit, 0.3 ms → 8 ms. Reject for VM. Either keep standard obfuscation only, debounce to once per blur, or compute discounts on the server.That is the whole method: measure each candidate, compare to a budget, protect only what passes.
A one-off local test decays. Add a small benchmark job that fails the build if a protected function regresses beyond a threshold:
# sketch: run the microbenchmark harness on the protected build
- run: npm run build:protected
- run: npm run bench:protected
# bench:protected exits non-zero if medians exceed budgets in budgets.json
Store the medians as artifacts so you can see trends when you change engines or add functions to the protected set.
When you want the strongest client-side layer on functions that passed the budget, VM-based protection compiles them to a custom bytecode run by an embedded interpreter. The original control flow is no longer in the file, which raises analysis cost. It is also the transform with the largest runtime hit — which is why measurement comes first. VEILVM is a browser-based JSVMP workspace: protect a candidate, drop the output into your harness, and keep or reject it based on numbers.
Honest limits still apply: client-side protection raises cost for attackers; it is not unbreakable. Secrets and authoritative decisions belong on the server. Do not put hot-path code through a VM just because you can.
If you want to try a VM build of one cold function and time it, VEILVM runs in the browser with no sign-up: 3 free runs a day, up to 5 KB per file (resets at 00:00 UTC). A free account raises the input limit to 30 KB. Pro is $9/month or $79/year and adds an API so protection and your benchmark job can share a CI pipeline. Standard requests are processed in memory and are not written to logs. Start at https://vm.ldvmp.com.