Compatibility

Does Obfuscation Break Memoization and Caches?

A cache is the one part of an application that is supposed to be invisible when it works. It has no output of its own, its correctness is defined by something not happening twice, and its failure mode is usually a number drifting somewhere far away from the code that caused it. That combination makes it worth measuring carefully, and it is where this pass found its quietest result: a cache that reports an eviction it did not perform.

What was measured

Four things, chosen because they are what caching code actually looks like. A textbook memoize wrapper around a counted function, so repeated calls are observable. A cache keyed by a string built from two arguments, with its own call counter. A full LRU with a size limit, recency reordering, and explicit hits, misses and evictions bookkeeping. And a time-to-live entry with a freshness check.

Every one of them prints numbers rather than objects, which matters for a cache: the observable property is how many times the underlying function ran, not what it returned. The unprotected run recorded two calls behind three memoized invocations, one call behind two keyed lookups, an eviction that removed the correct entry, and a stats line reading one hit, one miss, one eviction and a size of two.

Protected in five configurations -- the default target, the modern target, both gate profiles and the string-transform profile -- every number was identical. The memo wrapper still deduplicated, the string key still matched on the second lookup, the LRU still evicted the least recently used entry, and the TTL check still reported fresh and then stale at the right times.

The string-keyed cache is the arm worth pausing on, because a cache key assembled from values is exactly the kind of thing that looks like it should be at risk. It is not: the key is built from values at runtime, and the string transforms change where a literal is stored rather than what it evaluates to, so the key that goes into the map is the key that comes back out.

The bookkeeping you own is safe

A member pattern matching hits, misses, evictions and limit changed nothing observable on either target. So did a pattern matching map, stats and put. Every counter still counted, the limit still limited, and the stats line still read the same numbers.

That is the expected result and it is the useful baseline for this page. The fields of a cache object are read by the cache's own methods, a few lines away, and renaming both the declaration and every read is a consistent substitution. This is the majority of a cache by volume, and it is genuinely fine.

It also means a member pattern anchored to your own naming conventions is safe to apply to caching code, which is not something you can say about a transport layer or an options dictionary. The values in a cache are opaque to everything outside it, and the field names are private in practice even when the language does not enforce it.

One arm broke that pattern, and it broke it in a way that is invisible from inside the cache.

A cache that grows past its limit and says it did not

The pattern matched value, ttl and born -- three field names, all of which look like they belong to the file. Nothing threw. The measured result was that the LRU held three entries under a limit of two, the entry that should have been evicted was still present and readable, and the stats line reported two hits, zero misses, one eviction and a size of three.

Read the last two together. The cache reported one eviction and a size larger than its own limit, in the same string, and both numbers were produced by the cache's own instrumentation. Any dashboard reading those counters would show eviction working.

The mechanism is one line, and it is the idiom every LRU uses to find its oldest entry: this.map.keys().next().value. The emitted file shows it as keys().next()._0x1. The value being renamed is not the cache's own field -- it is the value of the iterator result object that the language hands back from next, and the pattern could not tell the difference because member renaming matches names rather than origins.

So the oldest key resolved to undefined, deleting undefined removed nothing, and the eviction counter incremented anyway because it sits on the next line and has no idea whether the delete succeeded. The cache grows without bound, memory climbs, and the metric built to detect exactly that failure reports healthy. The eventual symptom is a memory profile with no obvious owner, arriving long after the build that caused it.

Why this one is worth more than its size

The result matters because value is such an ordinary field name. A cache entry with a value, a config record with a value, a form model with a value -- any of them makes value a plausible entry in a member pattern, and there is nothing in the source to suggest that the same name is also part of a protocol the language uses.

It is the same shape as the iteration protocol result on this site, and the same shape as renaming an error's message, and the same shape as a renamed Intl options key. In each case the rename is perfectly self-consistent across the file, and in each case something outside the file reads the name in between. Self-consistency is not sufficient, and this is the cleanest demonstration of that yet measured here, because the failing read and the incrementing counter are two adjacent lines in the same method.

It is also the reason to be specific about what an eviction counter proves. A counter placed after an operation counts attempts, not effects. In unprotected code those are the same number often enough that nobody notices the difference; here they diverge silently. A cache that asserted on its own size after eviction would have caught this immediately, and that assertion costs one line.

The narrow lesson is to keep protocol names out of member patterns. The broader one is that instrumentation which reads only from the code it instruments cannot detect a failure of that code, and a cache is the place where that gap is most expensive.

The keyword-named methods fail differently

One more arm is worth recording because its diagnostic is misleading in a specific way. A pattern matching get, set and delete -- the natural method names for a cache -- threw TypeError: lru.get is not a function.

Note what the message says. It names lru.get, the real name from your source, rather than a generated one. Every other renaming failure on this site reports something like _0x1 is not a function, and that generated name is the usual signal that renaming is the cause. Here that signal is absent, and the error looks exactly like an ordinary mistake about an object's shape.

The cause is a known behaviour documented elsewhere on this site: these names are keywords, so the writer emits an access to them in quoted bracket form, which is not a rename site, while the declaration is renamed normally. The two halves disagree. It is worth knowing here specifically because get, set, put, delete and has are the most natural method names a cache can have, and a member pattern aimed at a cache is unusually likely to include them.

The practical guidance is unchanged and simple: leave them out of the pattern. But if you have already hit this, the diagnostic to trust is the source rather than the message -- confirm the method exists on the object, then check whether the emitted declaration still carries its original name.

How to check your own build

Assert on size, not just on hits. The single check that would have caught the eviction failure is one comparison of the cache's entry count against its configured limit after enough insertions to force eviction, and it does not depend on knowing anything about protection.

Count the underlying calls rather than comparing returned values. A memo wrapper that has stopped memoizing returns exactly the same answers as one that is working, so only a counter on the wrapped function can tell you which you have. That check is what confirmed the base column clean here, and it is the check most caching tests are missing.

Keep protocol names out of member patterns, and treat value, done and next as reserved for that purpose regardless of what they mean in your own model. If a field of yours is genuinely called value, either exclude the name or give the field a more specific one; the second is usually better code anyway.

Finally, if memory is climbing in a protected build and nothing is throwing, look at the eviction path before looking at the transforms in general. The failure measured here produces no error, no wrong value at the call site and no change to any number the cache reports about itself, so it will not appear in anything except the size of the process.

Frequently asked questions

Does obfuscation break memoization?

No. A memoize wrapper, a cache keyed by a string built from arguments, an LRU with recency reordering, and a TTL freshness check were all measured producing identical numbers in five protection configurations. The check that matters is a counter on the wrapped function rather than a comparison of returned values, because a broken memo wrapper returns the right answers too.

Are string cache keys affected by the string transforms?

No. A key built by joining argument values at runtime matched on the second lookup identically before and after protection, in every configuration including the string-transform profile. Those transforms change where a literal is stored and how it is encoded, not what it evaluates to, so the key going into the map is the key coming back out.

Why did my LRU cache stop evicting after protection?

Most likely because a member pattern included the name value. The idiom that finds the oldest entry reads map.keys().next().value, and the value there belongs to the iterator result the language returns, not to your cache. Renaming it makes the oldest key resolve to undefined, so the delete removes nothing while the eviction counter on the next line still increments.

Can a cache report evictions that did not happen?

Yes, and that was the measured result. The LRU reported one eviction and a size of three under a limit of two, in the same stats line. A counter placed after an operation counts attempts rather than effects, and the two only diverge when the operation silently does nothing. Asserting on the cache's size after eviction catches it in one line.

Is it safe to rename a cache's own fields?

Yes. Patterns matching hits, misses, evictions and limit, and separately map, stats and put, changed nothing observable on either target. Those names are read only by the cache's own methods a few lines away, so renaming the declaration and every read is a consistent substitution.

Why does the error name my real method instead of a generated one?

Because the method name is a keyword. Accesses to keyword-named properties such as get, set and delete are written in quoted bracket form, which is not a rename site, while the declaration is renamed normally, so the two halves disagree and the error quotes the original name. That removes the generated-name signal you would normally use to identify a renaming failure.

What is the smallest useful test for a cache on a protected build?

Two assertions. Count the calls to the underlying function to confirm the cache is still deduplicating, and compare the cache's entry count against its limit after forcing an eviction to confirm it is still bounded. Neither depends on knowing anything about the build, and together they cover both failure modes measured here.

Related reading