Diagnostics

Profiling and memory leaks in obfuscated JavaScript

There is a well-trodden path for reading a stack trace out of a protected build. There is no equivalent path for the memory and performance panels, and the reason is structural rather than a missing feature. A stack trace is text your build can map back. A heap snapshot is a picture of live objects, labelled with the names the engine is actually using.

One of the three panels is a solved problem

When a protected build throws, the stack trace names functions and file positions that do not exist in your source. That problem has a standard answer: produce a mapping at build time, keep it private, and translate the trace after the fact. The symbolication documentation covers the mechanism and there is a separate walkthrough of doing it against production errors.

It is easy to assume that mechanism generalises to the rest of the developer tools. It does not, and the reason is worth being precise about. A source map answers the question which position in the original file does this position in the output correspond to. That is exactly the question a stack trace poses. It is not the question the memory panel poses.

The memory and performance panels do not read your files. They read the objects and frames that exist in the running heap, and they label them with the names those objects and functions actually carry at runtime. Those names are the transformed ones, and there is no point in the rendering path where a mapping file could be consulted.

What a heap snapshot is actually showing you

A heap snapshot presents two kinds of name, and protection affects both.

The first is the constructor name that instances are grouped under. Here the behaviour splits in a way that is easy to predict. A class that is exported keeps its name, because export names have to survive for the module boundary to keep working, and the engine collects them before renaming precisely so they are preserved. A class that is local to its file is an ordinary binding and is renamed with everything else, so its instances are grouped under whatever short name the transform chose.

The second is the retainer path, which is spelled out as a chain of property names: this object is held by that property of that object, which is held by this other property, and so on up to a root. Member renaming rewrites dotted access and object-literal keys, so the chain reads as short names rather than as your vocabulary.

The net effect is specific and it is less bad than it first looks. You lose the vocabulary. You do not lose the structure. Counts, retained sizes, growth between snapshots and the shape of the retainer chain are all still exactly what they were, because none of them depend on what anything is called.

The performance panel and function names

A named function declaration is a binding, and the transform renames bindings, so a flame chart of a protected build is labelled with short names. Class methods are properties rather than bindings and go through the member transform instead, with the same visible result.

One part of this is not a regression at all. Anonymous functions and arrow functions were already showing up as an inferred name or as an anonymous frame before you protected anything, so for a codebase written in a modern style a meaningful share of the profile was never labelled with anything useful in the first place.

As with the heap, the structure survives intact. Relative cost, call depth, which subtree dominates a frame budget, how often a function is entered: all of that is measured rather than named, and all of it is still correct.

Which is fine, because the shape is the diagnosis

It is worth saying clearly that a leak is diagnosed structurally, not lexically. The standard method is to take a snapshot, perform the operation you suspect, take a second snapshot, and compare. What you are looking for is a set of objects that grows and is not collected, and then the chain that retains it.

None of those steps requires a readable name. You can establish that a particular constructor's instance count climbs by exactly one per navigation, that the retained set grows without bound, and that the retainer chain runs through a specific array on a specific long-lived object, entirely in the transformed vocabulary.

That is usually enough to identify the culprit from your own knowledge of the code, because you know which of your objects is long-lived and which array on it is appended to. The naming problem turns a five-minute investigation into a twenty-minute one. It does not stop the investigation.

Three ways to map back

When the shape alone is not enough, there are three practical routes and they are worth ranking, because the cheapest one is also the one people skip.

Reproduce it on a development build. A leak lives in your logic, not in the transform, so the same sequence of actions on an unprotected local build almost always produces the same growth with every name intact. This should be the first thing you try, and it resolves the large majority of cases.

Profile a protected build you can rebuild exactly. If the problem only appears in a protected build, pin the seed for that build so the same input and options give byte-identical output. That turns the artifact you profiled into an artifact you can regenerate and inspect at leisure, and it lets you correlate a short name in the snapshot with the same short name in a build sitting on your disk.

Isolate the pipeline. If you suspect the protection itself rather than your code, build with the naming options off and everything else unchanged. If the growth persists, naming was never involved and you have narrowed the search without giving anything up.

When the protection genuinely is involved

There are a small number of cases where measuring a protected build tells you something about the protection rather than about your application, and they are all in the runtime defense family rather than in the transforms.

Runtime defense options install timers, and timers have a cost on the main thread that has been measured separately. Debug-protection checks in particular behave differently on a backgrounded tab, which is its own article and its own class of confusing profile.

The name transforms themselves are source to source. They do not retain state beyond the string table, which is a single array allocated once at load and not added to afterwards. If your retained heap is growing, the transform is not what is growing it.

What protection does not change

It is worth ending on the list of things that are exactly as they were, because the temptation when a tool gets harder to read is to blame the tool.

Detached DOM nodes still leak. Listeners added and never removed still accumulate. Closures that capture a large object still keep it alive. An unbounded cache still grows without limit. Every one of those is your code, is present before and after protection, and is found the same way.

The transform changed the labels on the diagram. It did not change the diagram.

The short version

Stack traces are solved by symbolication; heap snapshots and CPU profiles are not, because they read names from live objects and frames rather than from a file that a mapping could be applied to. Exported class names survive, local ones are renamed, and retainer paths read as short property names. The structure of both panels is unaffected, and structure is what a leak investigation actually uses. Reproduce on a development build first, pin the seed when you need to profile a protected build you can rebuild, and remember that the leak itself is still yours.

Frequently asked questions

Do source maps fix a heap snapshot?

No. A source map answers which position in the original file a position in the output corresponds to, which is what a stack trace needs. A heap snapshot labels constructors and retainer paths from the live objects themselves, and no mapping file is consulted anywhere in that rendering path.

Will my class names appear in a heap snapshot after protection?

An exported class keeps its name, because export names have to survive for the module boundary to work and are collected before renaming. A class that is local to its file is an ordinary binding, so it is renamed and its instances are grouped under the new name.

Does obfuscation cause memory leaks?

The name transforms are source to source and retain nothing beyond the string table, which is a single array allocated once at load rather than a structure that grows. Runtime defense options do install timers, which is a separate question with its own measurements.

How do I profile a build I can also rebuild?

Pin the seed. The same input, options and seed produce byte-identical output, so the build you profiled is the build you can regenerate and inspect. Without it the default is per-build polymorphic output, and your next build will have chosen different names.

Is a flame chart still useful when every function is renamed?

The structure survives, and structure is most of what a profile is for: relative cost, call depth, and which subtree dominates. What you lose is the vocabulary, so the practical approach is to read the shape in the protected build and confirm the cause in one you can read.

Should I ship an unprotected build just for debugging?

Publishing one gives away the thing you were protecting. Reproducing the problem locally on a development build does not, and it is usually sufficient, because a leak lives in your own logic rather than in the transform.

Related reading