Performance

What runtime defense costs on the main thread

Performance questions about obfuscation almost always get answered with bundle size, and bundle size is the least interesting part. The measurable cost of a protected build, when there is one, usually comes from the runtime options rather than from the transforms — and the largest single item is not any of the timers people expect, but a change in how your program reaches the JavaScript engine at all. Here is the whole inventory, taken from the generated wrappers, and how to measure each piece on its own.

Two costs that get treated as one

There are two entirely different performance questions hiding inside "does obfuscation slow things down", and merging them is why the answers people get are so inconsistent.

The first is the cost of the transforms. Identifiers get renamed, statements get reordered, control flow gets flattened, strings get moved into a table. This changes the bytes, so it shows up as download size, parse time and sometimes a dent in compression ratio. It has been covered here before, in whether obfuscation slows down JavaScript and how much bigger it makes your bundle, and for most projects it is a smaller effect than expected.

The second is the cost of the runtime options, and it is a different kind of thing. These do not modify your code so much as wrap it in code that keeps running: timers that tick for the life of the page, checks that execute on every call into your bundle, and, for two of them, a change in how your program is handed to the JavaScript engine at all. This is the part that shows up in the metrics you are actually judged on, and it is the part almost nobody measures separately.

The largest cost is not a timer

Start with the item that dominates everything else, because it is structural rather than incremental.

Two of the wrappers do not leave your program sitting in the file as executable script. They hold the whole thing as a string and turn it back into code at runtime. The self-defending wrapper keeps the source in a variable and runs it through an indirect eval when its returned function is invoked. The anti-tampering wrapper compiles the same string through the Function constructor, preferring a constructor taken from a clean realm when one is available.

The behaviour is equivalent. The delivery is not. A normal script can be parsed lazily, with function bodies compiled when first called, and browsers keep a cache of compiled bytecode so a returning visitor skips much of that work. A program that only exists as a string until runtime does not get that treatment: it is compiled at the moment the wrapper runs it, in one piece, on every single load, with no reuse from the previous visit.

For a small script this is invisible. For a several-hundred-kilobyte application bundle it is the single most expensive consequence of enabling those options, and it lands during startup, which is exactly where it is most costly.

The timer inventory, at the shipped defaults

The recurring work is smaller than the compile cost, but it is worth knowing exactly what you have signed up for, because the defaults are not obvious from the option names.

  • Debug protection, pause check. One interval at the configured value, defaulting to one second. Each tick reads the wall clock and compares the gap against your interval plus five hundred milliseconds.
  • Debug protection, debugger timer. A second interval at the configured value plus eight hundred milliseconds, capped at sixty seconds, so one and eight tenths of a second by default. Each tick builds a function from a string and invokes it.
  • Anti-tampering verification. One interval at five seconds, walking the whole watch list.
  • Self-defending heartbeat. None by default. The interval is read as seconds, and an unset or unparseable value becomes zero, which emits no timer at all. Set it explicitly and you get a periodic integrity comparison.
  • Listeners. Debug protection also registers a resize handler and an input trap. These cost nothing until the corresponding event happens.

None of these are large on their own. Their significance is that they are unconditional, they run for as long as the tab is alive, and on a low-end device the per-tick work is not the fraction of a millisecond it is on a development laptop.

The check that runs on every call

This is the one that catches teams out, because it is not scheduled and therefore does not appear in any timer inventory.

The anti-tampering wrapper returns a function around your program. That function verifies before it delegates. So the watch-list walk happens on the five second timer and every time your entry point is invoked. Whether that matters depends entirely on the shape of your bundle. A single-entry application that boots once pays it twice and moves on. A library whose exported entry point is called repeatedly, or a bundle re-entered on user interaction, pays it every time, on the main thread, inside the interaction.

What the walk costs is a function of the list. Twenty-four default paths, each resolved property by property from the global object, each compared by identity against the startup reference, each passed through Function.prototype.toString with a string comparison against the captured text, and where a clean realm was captured, a second toString against the pristine built-in. That is a few hundred cheap operations, which is nothing at page load and is not nothing inside a hot path being measured for interaction latency.

If you have added paths with the include option, you have lengthened this walk in exact proportion.

Startup work you only pay once

Both the self-defending and anti-tampering wrappers build a hidden frame during initialisation to obtain untampered references to built-in functions. The frame is created, given a display style, appended to the document element, read from, and then removed again immediately. It is a genuine synchronous document operation during startup, and it happens once per wrapper rather than repeatedly.

Worth knowing for two reasons beyond the cost itself. It requires a document, so in any non-browser runtime the clean-realm comparison silently falls back to comparing the host's built-ins against themselves. And a content security policy tight enough to forbid frames prevents its creation, with the same quiet fallback. The performance impact is trivial; the change in what the check can detect is not.

Where this lands in the metrics

Translating the mechanisms above into the numbers a stakeholder will ask about:

  • Startup and main-thread blocking. The runtime compile of a string-delivered bundle is one large task at load. This is the mechanism most likely to move a blocking-time number and to delay whatever your largest paint depends on.
  • Interaction latency. Driven by the per-call verification, not by the timers. If your entry point participates in interactions, that is where to look first.
  • Steady-state timers. Modest, and they keep the page from being fully idle. Relevant on battery-constrained devices and for anything measuring background activity.
  • Repeat visits. Disproportionately affected, because the compiled-code caching that normally makes a second visit cheaper does not apply to a program that arrives as a string.

How to measure it without fooling yourself

The common mistake is comparing source against fully protected output and attributing the difference to obfuscation in general. That answers a question nobody asked, because it merges the two costs at the top of this page and gives you no way to act on the result.

Build a matrix instead. Take the protected artefact with all runtime options off as your baseline, then enable one guard at a time and re-measure. That isolates each cost, and it usually reveals that most of the regression comes from one or two options you could tune rather than from the protection step as a whole.

Two things to control for. Measure on hardware representative of your users, because everything described here scales with device speed and a fast laptop will flatter you. And check the build warnings before you trust the numbers at all: if the wrappers were skipped, you measured a build without them.

The options most likely to be skipped entirely

Before tuning anything, confirm the guards are running. The self-defending, self-healing and anti-tampering wrappers are classic-script constructs and the engine skips them when the source is an ES module, so that import and export linking remains valid, emitting a warning when it does. They are also skipped when the optimisation mode targets Node.

For a modern module-based build this can mean you are paying none of the cost above and receiving none of the protection you configured. That is worth discovering from a build log rather than from a penetration test. The same point, from the security side rather than the performance side, is covered in whether to obfuscate an edge function.

A short checklist

  • Read the build warnings first and confirm which wrappers were actually applied.
  • Measure protected-with-guards-off as your baseline, then add one guard at a time.
  • If startup regressed, look at the string-delivered wrappers before anything else.
  • If interaction latency regressed, look at how often your entry point is called.
  • Raise the debug-protection interval rather than leaving it at one second, and remember the second timer tracks it.
  • Keep the watch list to paths you would act on; every added entry lengthens a walk that runs on every call.
  • Choose the failure action deliberately. This page is about cost, not correctness, and the correctness question is covered in why the debug timer fires on a backgrounded tab.

The short version

The transforms are cheap and the guards are not. The largest single cost is not a timer but a delivery change: two wrappers hand your whole program to the engine as a string, which forfeits lazy parsing and cached compilation on every load. The second largest is a verification that runs on every call into your bundle rather than only on its timer. Both are worth paying in the right places, and neither should be paid by accident because an option looked harmless in a preset.

Frequently asked questions

Is the performance cost of obfuscation the renaming or the runtime guards?

Almost always the guards, and the two are worth measuring separately because they behave nothing alike. Renaming identifiers, reordering statements and flattening control flow change the bytes of your file, so their cost is mostly download and parse, and shorter identifiers can leave a minified bundle roughly where it started. The runtime options are different in kind: they add code that executes on the user's machine for the life of the page, and two of them change how your program is delivered to the engine. If you are trying to explain a regression, start with the option list rather than the file size.

What does it mean that the program is delivered as a string?

Two of the wrappers hold your entire protected program as a string literal and evaluate it when the entry point runs. The self-defending wrapper executes it through an indirect eval; the anti-tampering wrapper compiles it through the Function constructor. Functionally the result is the same program, but the engine reaches it by a different route. Code that arrives as an ordinary script can be parsed lazily and, in modern browsers, have its compiled form cached and reused on later visits. Code that materialises from a string at runtime is compiled when that call happens, on every load. For a large bundle that is the single biggest item on this page.

Which timers do the runtime options actually schedule?

At the shipped defaults, three. Debug protection schedules a pause check on a one second interval and a second timer that constructs and invokes a debugger statement, which sits at the interval plus eight hundred milliseconds, so one and eight tenths of a second by default. Anti-tampering schedules its verification on a five second interval. Self-defending schedules nothing unless you set its interval explicitly, because an unset or unparseable value is treated as zero and no heartbeat is emitted. Debug protection also registers a resize listener and an input trap, which cost nothing until they fire.

What does the anti-tampering check do each time it runs?

It walks a watch list of twenty-four default paths. For each entry it resolves the dotted path from the global object, compares the current value against the reference captured at startup, calls Function.prototype.toString on it and compares that text, and where a clean realm was available performs a second toString against the pristine copy. Individually these are small operations. The part that surprises people is the schedule: the check runs on the five second timer and also on every single call of the wrapped entry point, so in a bundle whose entry point is invoked during user interaction the work lands directly in your interaction latency rather than in idle time.

How should we measure this properly?

Measure the protected artefact, never the source, and vary one option at a time. The useful experiment is a matrix rather than a before-and-after: baseline protected output with the runtime options off, then each guard added on its own, on the same hardware and the same network profile. Lab tooling on a fast laptop will understate all of it, because the per-call and per-tick work scales with how slow the device is. Pair it with field data if you collect it, and look at interaction latency and main-thread blocking rather than at bundle size, which is measuring the other cost entirely.

Do these wrappers apply to a modern ES module build?

Often not, and this catches teams in both directions. The self-defending, self-healing and anti-tampering wrappers are classic-script constructs, and the engine skips them for ES module source so that import and export linking stays valid, emitting a build warning when it does. If your build is modules all the way down, you may be paying none of the runtime cost described here and also receiving none of the protection you configured. All three are likewise skipped when the optimisation mode targets Node. Read the build warnings rather than assuming an option took effect.

Related reading