You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The hard part of cache design is invalidation. webpack's persistent cache, memory cache, and incremental computation can seem tightly intertwined, but three questions help separate their responsibilities: What inputs does a cached result depend on? How are changes to those inputs detected? How long can the result be reused?
This article examines those designs through the linked webpack source code and compares them with public implementations and examples from Rspack and Turbopack. Implementation details refer to the versions in the corresponding links.
Caching: Correctness and Performance
Caching avoids repeated computation by reusing results. Correct invalidation must account for every input that can affect a result, while validation must cost less than recomputing it.
Correctness: Every change to an input that can affect the result must be considered when deciding whether to invalidate it.
Cost: Checking whether inputs have changed should be substantially cheaper than recomputing the result.
webpack's more complex caching strategies ultimately balance these two goals.
Borrowing terminology from Salsa, we can loosely divide the relevant inputs and computations into two categories. This is an analogy, rather than webpack's official classification:
input: Direct external inputs, such as source files, third-party dependencies, configuration files, and environment variables. Filesystem inputs are primarily validated through snapshots. Non-file inputs, such as environment variables, still need to participate explicitly in a cache version or another invalidation condition.
tracked: Intermediate computations, such as module hashes and chunk hashes. An etag can represent the version of the inputs relevant to such a computation.
Etags and snapshots both help determine whether cache dependencies have changed: an etag compares a version marker, while a snapshot validates filesystem state.
webpack's general cache interface locates an entry by identifier and validates its version with an etag. For inputs that require filesystem validation, a caller may set the etag to null and separately validate a snapshot through an interface such as checkSnapshotValid().
Etags avoid directly comparing complex inputs for deep equality, but generating an etag also has a cost. When a hash serves as the etag, it must encode the relevant inputs completely and keep collision risk under control.
For example, the core read logic in MemoryCachePlugin looks like this:
compiler.cache.hooks.get.tap({name: "MemoryCachePlugin",stage: Cache.STAGE_MEMORY},(identifier,etag,gotHandlers)=>{constcacheEntry=cache.get(identifier);if(cacheEntry===null){returnnull;}elseif(cacheEntry!==undefined){// Return the cached result only when the etag matches.returncacheEntry.etag===etag ? cacheEntry.data : null;}gotHandlers.push((result,callback)=>{if(result===undefined){cache.set(identifier,null);}else{cache.set(identifier,{ etag,data: result});}returncallback();});},);
ResolverCachePlugin, on the other hand, obtains a cache entry with a null etag and checks its snapshot separately:
A snapshot is webpack's central structure for validating filesystem inputs. Whether a module can reuse a cached result depends on the validity of its dependency snapshot as a whole, not just a single timestamp or content hash.
Primarily compare timestamps, accounting for timestamp precision and safe time windows
Development builds where validation speed matters
Content hash
Compare content hashes
CI environments where files are frequently fetched again and timestamps are unstable
Timestamp + content hash
Return quickly when timestamp checks establish validity; otherwise, validate the content hash
Environments where most timestamps remain stable but unnecessary rebuilds should be avoided
Timestamp comparisons are fast, but timestamps can change without a content change. Checking out files, extracting dependencies again, or having an editor rewrite identical content can all cause unnecessary invalidation. Running git fetch alone does not rewrite the working tree, so it should not be confused with these operations.
If most file timestamps are unstable, storing and comparing them offers less benefit. If most files have not been rewritten, checking timestamps first and hashing only when necessary is usually more useful.
Timestamp Precision
Equal timestamps do not necessarily imply equal contents. If a filesystem has a timestamp resolution of one second, two modifications within that second may receive the same timestamp.
webpack's filesystem information includes more than raw timestamps. It also accounts for timestamp precision, safeTime, and the snapshot's startTime when dealing with unsafe time windows. See the snapshot validation logic in FileSystemInfo.js.
Timestamp + hash mode therefore cannot be reduced to an unconditional rule that equal timestamps allow reuse. Handling time windows also does not make every content change that deliberately preserves timestamps safe to ignore.
Module build dependencies; timestamp + hash in production mode, timestamp only otherwise
snapshot.contextModule
Context Module dependencies; timestamp only by default
snapshot.resolve
Module resolution dependencies; timestamp + hash in production mode, timestamp only otherwise
snapshot.buildDependencies
Build dependencies that affect the persistent cache as a whole; timestamp + hash by default
snapshot.resolveBuildDependencies
Dependencies discovered while resolving build dependencies; timestamp + hash by default
Dependencies
A snapshot records the dependencies of a computation. Relevant changes to those dependencies invalidate the snapshot.
File dependencies: Files such as module source or configuration files. Changes to relevant files require validation.
Missing dependencies: Paths that did not exist. For example, if a resolver falls back to index.json because index.js is missing, the later appearance of index.js may change the resolution result.
Context dependencies: Directory dependencies. Computations that enumerate directories can be affected by added or deleted files and directory-content changes. Validation cost may grow with the size of the directory.
For example, require.context() and the import.meta.glob() feature provided by some tools establish directory dependencies. This compares similar mechanisms; it does not mean webpack natively supports the latter. Adding the project root to a scan requires particular care around dependency-tracking scale and validation cost.
Another subtle detail is that a directory read during resolution does not necessarily become a context dependency. In the webpack implementation referenced here, relevant directories can also be added to the resolve snapshot as file dependencies. That has different semantics from recursively scanning the entire directory.
Build Dependencies
webpack tracks dependencies introduced by buildDependencies, including relationships established through require and import. It does not automatically turn arbitrary fs reads into build dependencies, so file and non-file inputs still need to be declared correctly.
This process uses a dedicated resolver. It should not be assumed to inherit all of the application's resolve configuration. See FileSystemInfo.js.
webpack also uses require.cache to analyze dependencies of loaded CommonJS modules. Build-dependency analysis can therefore interact with the actual behavior of the Node.js loader. Tools that collect dependencies differently may handle aliases differently as a result. Rspack issue #13734 documents a build-dependency resolution case involving TypeScript paths.
Managed Paths and Immutable Paths
Large projects have many dependencies to track. webpack uses additional assumptions to reduce the cost of snapshot computation.
Immutable paths assume that the contents of a path never change, or that a content change also changes the path. Some package caches, for example, encode a content identifier in the path. When this assumption holds, ordinary content validation can be skipped.
Managed paths assume that a package manager maintains the directory and that package contents change when the package's identity or version changes. webpack can use information such as the name and version in package.json to avoid validating every file in the package.
Reading package metadata is different from hashing the entire package. The optimization depends on that metadata representing the package contents; it does not prove that every file is unchanged.
When manually editing node_modules, applying package patches, or linking local packages, check whether the managed-path assumptions still hold. A mutable local package's real path should not be incorrectly treated as an immutable managed dependency. Adjust snapshot.managedPaths or snapshot.unmanagedPaths when necessary.
Snapshots validate filesystem inputs, while etags can represent versions of computation inputs. A code-generation example makes the distinction more concrete.
The following TypeScript examples illustrate cache design. Definitions of types such as Module and Config are omitted.
The function reference remains unchanged, but its result depends on mutable state in the closure.
Comparison cost is another problem. Suppose a snapshot contains many paths:
use std::collections::HashSet;use std::path::PathBuf;structSnapshot{file_dependencies:HashSet<PathBuf>,missing_dependencies:HashSet<PathBuf>,context_dependencies:HashSet<PathBuf>,}
Comparing these collections may involve path comparisons, hash computation, and set lookups. With sufficiently complex inputs, equality checks can approach or even exceed the cost of recomputation.
Approach 3: Represent Relevant Input Versions with an Etag
First compute the filename that actually affects the output, then combine it with an etag for the module's contents:
This example assumes that the module hash already covers the other inputs required for rendering. filename is evaluated only once, so a stateful function cannot return different values when constructing the etag and rendering the result.
Etags have two core requirements.
First, account for every factor that affects the result.
Omitting an input can easily produce incorrect caching. For example, Rspack issue #14873 describes a chunk-render cache that did not include the output path. When a filename function moves a chunk into a deeper directory, relative asset URLs inside that chunk may need to change even if the chunk's content hash is unchanged.
Encoding version information directly in the key is also possible, but it can increase the cost of constructing, storing, and comparing keys. Separating a stable identifier from a changing etag makes their responsibilities clearer.
Second, when a hash serves as the version marker, encode the inputs completely and reduce collision probability.
A content hash used to determine cache validity has a different responsibility from the hash used to locate a bucket in a HashMap or HashSet:
If a cache relies only on a content hash to decide whether a result can be reused, a collision can affect correctness.
Rust's standard collections use Eq to distinguish keys after a hash collision. Assuming the Hash/Eq contract holds, collisions primarily affect performance.
The following ordinary functions illustrate the difference; they are not implementations of the actual RspackHash trait:
use std::hash::{Hash,Hasher};// Unsuitable as a full-content cache fingerprint: bytes after the first 100 are ignored.fnhash_prefix<H:Hasher>(value:&str,state:&mutH){let bytes = value.as_bytes();
bytes[..bytes.len().min(100)].hash(state);}// Hash the full string; a real cache also needs a suitable algorithm and digest length.fnhash_full_content<H:Hasher>(value:&str,state:&mutH){
value.hash(state);}
If a key's Eq implementation compares the entire string, a prefix hash can still satisfy the requirement that equal values have equal hashes when used for bucket selection. It may simply cause many collisions. Used directly to determine cache-content equality, however, it systematically misses suffix changes.
Every fixed-size hash can collide. Encoding all inputs does not mathematically eliminate that possibility.
Rebuilds in Development and Production
webpack creates a new Compilation for both development rebuilds and production builds, reusing cached results that remain valid during the process.
Development: A watcher triggers a rebuild after file changes, or a caller can invoke watching.invalidate() explicitly. That public method does not require the caller to supply a list of changed files.
Production builds: A new process can restore entries from the filesystem cache and validate them as they are used.
The public arguments to invalidate() should be distinguished from the information passed internally by the watcher. webpack's watcher provides file timing information and sets of modified and deleted paths. This information participates in updating filesystem state; it is not merely data for plugins to consume. See Watching.js.
For comparison, the referenced Rspack persistent snapshot implementation computes sets of modified, deleted, and unchanged paths. This illustrates another way to organize invalidation information: restore state and compute the change sets first, then use that information in subsequent build work.
Lazy Compilation and Cache Lifetimes
Development rebuilds are not always triggered by file changes. With lazy compilation enabled, a browser request for an inactive entry can also trigger a compilation.
Consider the navigation sequence A → B → A. Entry activation, the current ModuleGraph, and cache lifetimes are three separate concerns:
After A becomes inactive, dependency relationships that the current graph no longer needs can be removed to reduce subsequent work and memory use.
If all reusable build results are discarded at the same time, visiting A again may incur a substantial rebuilding cost.
If every visited module and intermediate result remains in memory indefinitely, memory use may keep growing throughout a long development session.
Removing inactive modules from the current graph and retaining recoverable cached results can therefore be designed separately. Whether a result can be reused still depends on the cache layer, validity checks, and eviction policy.
In Next.js 16.3, Turbopack uses filesystem persistence to support memory-cache eviction and reduce memory use during long development sessions. The official examples report the following measurements after compiling 50 routes:
type: "filesystem": Enable filesystem caching, usually with a memory layer for frequently accessed entries. An L1/L2 analogy is useful for understanding the relationship.
Both modes involve memory, but they use different configuration options:
Setting
Memory mode
Filesystem mode
Generational eviction of memory entries
cache.maxGenerations
cache.maxMemoryGenerations
Cache computation results for unaffected modules
cache.cacheUnaffected
cache.memoryCacheUnaffected
Prerequisite for the preceding feature
experiments.cacheUnaffected
experiments.cacheUnaffected
Disk-cache expiration policy
Not applicable
cache.maxAge
The generation options also have special values. For example, maxMemoryGenerations: 0 in filesystem mode disables the additional memory-cache layer, so L1 is not necessarily enabled under every configuration. See WebpackOptionsApply.js for how the layers are configured, and the webpack Cache documentation for option details.
Generational GC
webpack uses generations to measure how many compilation rounds an entry has gone without being accessed and evicts entries that remain inactive. This is neither a wall-clock timer nor an exact limit on memory usage in bytes.
Memory mode uses cache.maxGenerations.
The memory layer in filesystem mode uses cache.maxMemoryGenerations.
Filesystem caching also has a separate cache.maxAge. It limits how long entries in disk packs may remain unused. Expired entries are discarded during pack maintenance and subsequent persistence, rather than immediately rewriting individual disk entries when their age limit is reached. See PackFileCacheStrategy.js.
flowchart TD
Read[Cache lookup] --> L1["L1: Memory cache<br/>maxMemoryGenerations"]
L1 -->|Miss| L2["L2: Disk pack cache<br/>maxAge"]
L2 -->|Missing or invalid| Compute[Recompute]
L2 -->|Populate L1 after a hit| L1
Loading
Persistence also requires care around file replacement. If an existing pack may still be read, data in use cannot be arbitrarily overwritten or deleted. Writing temporary files and then switching file versions is one mechanism to consider when implementing such storage.
Populating L1 After an L2 Hit
On an L1 miss, the memory layer can register a gotHandler and continue reading from the next layer. After L2 returns a result, Cache.get() invokes the handler before completing the read, storing the result and etag in L1. Subsequent reads in the same process can then hit memory directly.
This allows entries evicted from memory to be restored from disk without full recomputation. It is particularly valuable for expensive work such as module builds, and it provides a foundation for using disk caching to support memory eviction in development sessions that visit many routes.
Populating the cache and validating an entry are separate responsibilities: a result's presence on disk does not make it valid for the current inputs. Callers that require filesystem snapshots still need to validate them. Other entries must be checked against their etags or other invalidation conditions.
Any bundler implementation should distinguish between two capabilities:
Restoring state from the persistent cache once at startup.
Supporting memory eviction, fallback to disk, and repopulation of memory throughout the process lifetime.
Implementing the first does not automatically provide the second. Likewise, exposing a generation option does not mean that every cache entry participates in a common eviction mechanism.
Deferring Persistence with an Idle Timeout
In development, persistence should avoid occupying the HMR critical path for long periods. webpack's IdleFileCachePlugin schedules disk writes after entering an idle phase, using options such as idleTimeout.
During rapid edits, deferring writes can reduce repeated persistence work and prevent serialization from one compilation from competing with the next build for resources. The related options cover ordinary idle periods, the initial store, and storage after large changes.
This suggests a useful implementation check: if snapshot saving, serialization, or disk writes perform substantial work on the rebuild critical path, enabling persistent caching can actually slow down HMR.
Cache Lifetimes and Dependency Scope
Lifetimes
Caches in webpack, Rspack, and Turbopack can be divided into three categories based on how long their results remain reusable:
Lifetime
Meaning
Design requirements
Across processes
Results can be restored after the process exits and restarts
Stable identifiers, serialization and deserialization, and complete validity checks
Across Compilations
A later compilation in the same process can reuse earlier results
Track input changes across rounds and manage object and cache lifetimes
Within one Compilation
Stages or calls within the current compilation share results
Define stage boundaries and clear or update caches when state changes
These lifetimes impose different requirements on keys, invalidation, and restoration cost. Choosing an appropriate reuse scope is an important part of a high-performance cache design.
Dependency Scope of a Computation
Another useful distinction is how much context a computation depends on:
Primarily the module's own build inputs: For example, a module build, including files and other dependencies declared by its loaders.
The module itself and the modules it references: For example, provided-exports information propagated through export *.
Referencing modules or broader graph state as well: For example, a module's used-exports information.
The latter two categories lead to the boundary between cacheUnaffected and global effects.
Graph Computation: CacheUnaffected and Global Effects
Both cache.cacheUnaffected for memory caching and cache.memoryCacheUnaffected for filesystem caching require experiments.cacheUnaffected. They primarily serve repeated compilations within the lifetime of one Compiler, especially development rebuilds.
webpack's caching can be broadly divided into entries managed through the general cache interface and in-memory computation results retained for unaffected modules. The two mechanisms complement each other.
CacheUnaffected Versus the General Cache Interface
Dimension
General cache interfaces such as cache.store()
cacheUnaffected
Role
General caching infrastructure
Incremental-computation optimization within one process
Cached data
Results located by an identifier and version marker
Intermediate results associated with objects such as Module and Dependency
Key
Stable string identifier
Objects or arguments such as Module, Dependency, and runtime
Validity checks
Etag and any additional checks required by the caller
buildInfo, references, effect propagation, chunk graph, and related state
Lifetime
Across Compilations; across processes with the filesystem backend
Across Compilations within the current Compiler process
Storage
Memory, disk, or other cache backends
Memory
Access
Usually asynchronous get()
Usually synchronous Map.get() or WeakTupleMap.get()
Restoration cost
May include disk I/O and deserialization
Directly reuse objects still on the heap
Invalidation granularity
The entry located by an identifier and its version
Modules and effects propagated through relevant dependency relationships
Using the filesystem backend requires identifiers that remain stable across processes. A module can use its identifier, but dependency objects and temporary IDs may not be stable across process lifetimes. Making every cache persistable adds identifier-conversion, persistence, and validation costs. See the Rust compiler's discussion of incremental persistence for related challenges.
In contrast, cacheUnaffected can use the identities of live objects and their graph relationships, avoiding the need to produce a complete, persistable input summary for every graph computation.
Local and Nonlocal Computations
When a result's inputs can be represented completely at low cost, the general cache interface is straightforward to use: construct an identifier, compute an etag, and validate a snapshot if needed.
For results that depend on ModuleGraph, ChunkGraph, or other computed results, generating a complete etag may be expensive. cacheUnaffected attempts to reuse in-memory results by establishing that the current changes have not affected the module.
This does not restrict cache.store() to local computations. The distinction is the cost of representing inputs and proving that results remain valid.
For example:
module.build primarily depends on the module's declared build inputs. If all inputs are tracked and the loader obeys the caching contract, its result can be reused based on snapshots and other applicable conditions.
Provided-exports information describes export names and their status, not exported values. Changes along an export * chain can change a module's export set even when its own source is unchanged.
Here, index.js exports a, b, c, d, and e. If lib3.js changes to:
exportconstd=40;
then the export set of index.js changes even though its own source has not changed.
An etag could theoretically include every dependency introduced through export *, but computing those etags may repeatedly traverse the dependency graph. Reusing results based on which modules are affected can reduce that cost. In simplified pseudocode:
affectedModules = calculateAffectedModules(changedModules)
if indexModule not in affectedModules:
reuse indexModule's cached provided-exports analysis
Computing Affected Modules
webpack's _computeAffectedModules() does not directly compare every module's export set. It checks buildInfo references, the Modules targeted by Dependencies, and how effects propagate through dependency relationships.
There are two distinct stages:
Snapshot invalidation determines whether the module itself needs to be rebuilt.
Changes to post-build state and references determine which computation caches must be invalidated across Compilations.
Propagation from a changed dependency to referencing modules also takes the dependency's effect type into account. It does not unconditionally invalidate every module reachable through reverse edges.
The export names have not changed, but that alone does not establish that referencing modules are unaffected. Rebuilding the module may replace its buildInfo object, triggering conservative cache invalidation and propagation. A particular export-analysis result being theoretically reusable does not mean this implementation will retain its cache.
ModuleMemCaches and ModuleMemCaches2
Three webpack fields are easy to confuse:
Field
Responsibility
compiler.moduleMemCaches
Retain each module's buildInfo, reference information, and first-level cache across Compilations
compilation.moduleMemCaches
Map Modules to first-level cache objects in the current Compilation
compilation.moduleMemCaches2
Map Modules to second-level cache objects in the current Compilation
The latter two Maps belong to the current Compilation. They can be recreated for a new round while continuing to reference WeakTupleMap cache objects from the previous round that remain valid. The diagram below omits the individual Module keys:
flowchart TD
Store["compiler.moduleMemCaches<br/>Module cache entries retained across Compilations"]
L1["First-level memCache: WeakTupleMap<br/>Module analysis and dependency-graph queries"]
Wrapper["key = memCache2<br/>references + memCache"]
L2["Second-level memCache2: WeakTupleMap<br/>Module hashes and runtime requirements"]
A1["Compilation A<br/>moduleMemCaches"]
B1["Compilation B<br/>moduleMemCaches"]
A2["Compilation A<br/>moduleMemCaches2"]
B2["Compilation B<br/>moduleMemCaches2"]
Store -->|item.memCache| L1
L1 --> Wrapper
Wrapper --> L2
A1 --> L1
B1 -->|Reuse while valid| L1
A2 --> L2
B2 -->|Reuse after validation| L2
Loading
These caches primarily hold intermediate results associated with modules. They are not a second store of module source, ASTs, or build output.
First-Level MemCache
Accessing the first-level cache involves two lookups:
After modules have been built, _computeAffectedModules() compares them with the previous round's baseline:
New module: Create a new memCache.
Changed buildInfo reference or tracked dependency target: Create a new memCache.
Stable module and relevant references: Reuse the previous memCache.
Referencing module affected by propagated dependency changes: Update its cache according to the effect type.
Module no longer present or lacking buildInfo: Remove the corresponding entry retained across Compilations.
The first-level cache can therefore survive multiple Compilations until a relevant change occurs in the module, its dependency targets, or the chain of propagated effects.
Second-Level MemCache2
The second-level cache needs further validation after module and chunk IDs have been assigned during the seal phase.
Validity conditions:
The first-level cache is still valid.
The current module's ID is unchanged.
Referenced modules' IDs are unchanged.
The sequence of chunk IDs associated with asynchronous blocks is unchanged.
The referenced source types are unchanged.
Typical entries:
Key
Value
"moduleRuntimeRequirements-" + runtimeKey
Set<RuntimeGlobals>, or null to cache the absence of runtime requirements
No second-level cache exists in the first-level cache: Create a new memCache2.
Validation information for IDs, asynchronous blocks, or source types has changed: Replace memCache2.
All validation information is stable: Continue using the old memCache2.
How the Two Levels Differ
Suppose a module's source and dependency relationships remain unchanged, but its module ID changes from 10 to 15. The first-level cache may still be reused, while the second-level cache must be replaced.
Change
First-level cache
Second-level cache
All relevant conditions remain unchanged
Reuse
Reuse
The current module's ID or a referenced module's ID changes
May be reused
Replace
An asynchronous block's chunk-ID sequence changes
May be reused
Replace
Referenced source types change
May be reused
Replace
buildInfo changes
Replace
Recreate with the first-level cache
A tracked Dependency points to a different target
Replace
Recreate with the first-level cache
Global Effects and the Limits of CacheUnaffected
webpack's cacheUnaffected is not an incremental switch that can be enabled independently for each stage. A useful way to understand it is to focus on changes to a module and the modules it references. That, however, does not cover the full dependency scope of every computation.
Some computations also depend on consumers or on the module graph as a whole. Propagating changes only from dependencies to referencing modules cannot cover their entire invalidation set. webpack calls these broader influences global effects.
A typical example is FlagDependencyUsagePlugin: which exports of a module are used depends on its consumers and the exports those consumers reference.
The first compilation:
// index.jsimport{foo}from"./lib.js";console.log("foo:",foo);// lib.js: foo is the used export.exportconstfoo=42;exportconstbar=43;
The source of lib.js remains unchanged, but its used exports change from foo to bar. Here, a change in the consumer affects the producer.
The public reproduction for cacheUnaffected and usedExports deliberately removes webpack's guard against the incompatible configuration and forces both features on. The second compilation incorrectly reuses module hashes and generated results, producing output that differs from a fresh build.
Unmodified webpack rejects this combination, so the reproduction should not be interpreted as an error that occurs under the default configuration. See the guard in FlagDependencyUsagePlugin.
The required chain of updates is:
The entry changes which exports it imports
→ The referenced module's used exports change
→ Dependent computations, such as module hashes, are invalidated
→ The relevant code is regenerated
Updating export state without invalidating downstream caches can still produce stale code. An implementation that maintains incremental results by stage must identify what each stage changes and which later results depend on those changes. A plugin's name alone is not enough to determine the invalidation scope.
The following optimizations all encounter this boundary:
Optimization
Plugin
Primary affected data
Source of the global effect
optimization.usedExports
FlagDependencyUsagePlugin
Export usage recorded per runtime
Consumers and usage relationships in the dependency graph affect a module's tree-shaking state
optimization.mangleExports
MangleExportsPlugin
An export's used name
Name assignment depends on the complete export and usage state
optimization.concatenateModules
ModuleConcatenationPlugin
Module graph, chunk graph, concatenated modules, and downstream hashes
Cross-module optimization changes module boundaries and related graph structures
Although cacheUnaffected helps development rebuilds, it does not automatically make every production optimization incremental. These webpack plugins reject combinations involving incompatible global effects. General memory and persistent caches can still provide reuse at other levels.
Incremental production optimization is difficult because production builds seek global optimization, while incremental computation tries to keep the impact of a change local. The same tension appears in cross-module optimizations such as Rust LTO. See Challenges for Incremental Production Optimizations for a related discussion.
Other Cache Interfaces and Their Assumptions
Beyond cacheUnaffected and the general cache interface, webpack has several narrower cache options with different invalidation assumptions.
Module Unsafe Cache
module.unsafeCache reuses the result of resolving a Dependency to a Module across Compilations.
In the referenced default configuration, enabling the top-level cache selects modules whose nameForCondition() path contains node_modules. When caching is disabled, the default is false.
Value
Meaning
false
Disable the cache
true
Broaden module selection, while still requiring conditions such as a cacheable factory result
(module) => boolean
Define which modules may use unsafe caching
The basic process is:
Resolve Dependency D → Module M normally during the first compilation.
Check whether the module matches the predicate and whether the factory result permits caching.
Store D → M in a mapping keyed by the Dependency object.
If a later Compilation reuses the same Dependency object, it may reuse the resolution result and skip resolution/factorization.
A hit depends on the Dependency object's identity, not the request string. A rebuild that produces a new Dependency object will not hit the mapping for the old object.
Old and new Compilations may hold the same Module instance while maintaining different ModuleGraphs. Even when different graph objects manage graph relationships, fields on the Module itself, such as dependencies, buildInfo, and source, can still be shared mutable state. An old Compilation is therefore not a fully immutable historical snapshot.
The cache is called unsafe because it uses stronger assumptions of stability to omit some invalidation checks. Changes to packages, resolution options, rules, loaders, or plugin behavior may leave stale resolution results in use. The default focuses on node_modules because third-party dependencies are usually relatively stable during a watch session.
A factory can also disable this reuse with factoryResult.cacheable = false. See the exact conditions in Compilation.js.
Loader Cacheable
NormalModule.needBuild() considers module state, cacheability, snapshots, and other conditions when deciding whether to rebuild.
When a loader uses additional inputs, it should declare dependencies so those inputs participate in invalidation wherever possible. If the inputs cannot be tracked reliably, or the same known inputs can still produce different results, the loader can call:
this.cacheable(false);
This marks that module-build result as uncacheable. Virtual modules and loaders that depend on mutable external state need particularly clear input and lifetime rules. See the Loader Interface.
Resolve Cache and Resolve Unsafe Cache
Both cache resolution, but their validity assumptions differ:
Option
Validation
resolve.cache
Uses webpack's cache and resolve snapshots to determine whether results can be reused
resolve.unsafeCache
Reuses results under stronger assumptions of stability, without equivalent snapshot validation
The default value of resolve.cache follows whether the top-level cache is enabled. According to the webpack Resolve documentation, resolve.unsafeCache is disabled by default and must be configured explicitly. The two operate at different layers. When enabling unsafe caching, check its assumptions whenever a resolution result also depends on mutable inputs outside the cache key.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
webpack Persistent Cache and Memory Cache
Chinese version
The hard part of cache design is invalidation. webpack's persistent cache, memory cache, and incremental computation can seem tightly intertwined, but three questions help separate their responsibilities: What inputs does a cached result depend on? How are changes to those inputs detected? How long can the result be reused?
This article examines those designs through the linked webpack source code and compares them with public implementations and examples from Rspack and Turbopack. Implementation details refer to the versions in the corresponding links.
Caching: Correctness and Performance
Caching avoids repeated computation by reusing results. Correct invalidation must account for every input that can affect a result, while validation must cost less than recomputing it.
webpack's more complex caching strategies ultimately balance these two goals.
Borrowing terminology from Salsa, we can loosely divide the relevant inputs and computations into two categories. This is an analogy, rather than webpack's official classification:
webpack's general cache interface locates an entry by
identifierand validates its version with anetag. For inputs that require filesystem validation, a caller may set the etag tonulland separately validate a snapshot through an interface such ascheckSnapshotValid().Etags avoid directly comparing complex inputs for deep equality, but generating an etag also has a cost. When a hash serves as the etag, it must encode the relevant inputs completely and keep collision risk under control.
For example, the core read logic in
MemoryCachePluginlooks like this:ResolverCachePlugin, on the other hand, obtains a cache entry with a
nulletag and checks its snapshot separately:Filesystem Inputs: Snapshots
A snapshot is webpack's central structure for validating filesystem inputs. Whether a module can reuse a cached result depends on the validity of its dependency snapshot as a whole, not just a single timestamp or content hash.
The Snapshot Data Structure
The Snapshot class in FileSystemInfo.js contains the following fields. Type annotations and methods are omitted here:
These fields fall into several broad groups:
_flags,_cached*IterablestartTimefileTimestamps,fileHashes,fileTshscontextTimestamps,contextHashes,contextTshsmissingExistencemanagedItemInfo,managedFiles,managedContexts,managedMissingchildrenSnapshot Validation Strategies
webpack supports three main strategies:
Timestamp comparisons are fast, but timestamps can change without a content change. Checking out files, extracting dependencies again, or having an editor rewrite identical content can all cause unnecessary invalidation. Running
git fetchalone does not rewrite the working tree, so it should not be confused with these operations.If most file timestamps are unstable, storing and comparing them offers less benefit. If most files have not been rewritten, checking timestamps first and hashing only when necessary is usually more useful.
Timestamp Precision
Equal timestamps do not necessarily imply equal contents. If a filesystem has a timestamp resolution of one second, two modifications within that second may receive the same timestamp.
webpack's filesystem information includes more than raw timestamps. It also accounts for timestamp precision,
safeTime, and the snapshot'sstartTimewhen dealing with unsafe time windows. See the snapshot validation logic in FileSystemInfo.js.Timestamp + hash mode therefore cannot be reduced to an unconditional rule that equal timestamps allow reuse. Handling time windows also does not make every content change that deliberately preserves timestamps safe to ignore.
Snapshot Configuration by Use Case
Different computations need different validation strategies. The following defaults come from webpack's snapshot configuration defaults:
snapshot.modulesnapshot.contextModulesnapshot.resolvesnapshot.buildDependenciessnapshot.resolveBuildDependenciesDependencies
A snapshot records the dependencies of a computation. Relevant changes to those dependencies invalidate the snapshot.
index.jsonbecauseindex.jsis missing, the later appearance ofindex.jsmay change the resolution result.For example,
require.context()and theimport.meta.glob()feature provided by some tools establish directory dependencies. This compares similar mechanisms; it does not mean webpack natively supports the latter. Adding the project root to a scan requires particular care around dependency-tracking scale and validation cost.Another subtle detail is that a directory read during resolution does not necessarily become a context dependency. In the webpack implementation referenced here, relevant directories can also be added to the resolve snapshot as file dependencies. That has different semantics from recursively scanning the entire directory.
Build Dependencies
webpack tracks dependencies introduced by
buildDependencies, including relationships established throughrequireandimport. It does not automatically turn arbitraryfsreads into build dependencies, so file and non-file inputs still need to be declared correctly.This process uses a dedicated resolver. It should not be assumed to inherit all of the application's
resolveconfiguration. See FileSystemInfo.js.webpack also uses
require.cacheto analyze dependencies of loaded CommonJS modules. Build-dependency analysis can therefore interact with the actual behavior of the Node.js loader. Tools that collect dependencies differently may handle aliases differently as a result. Rspack issue #13734 documents a build-dependency resolution case involving TypeScriptpaths.Managed Paths and Immutable Paths
Large projects have many dependencies to track. webpack uses additional assumptions to reduce the cost of snapshot computation.
Immutable paths assume that the contents of a path never change, or that a content change also changes the path. Some package caches, for example, encode a content identifier in the path. When this assumption holds, ordinary content validation can be skipped.
Managed paths assume that a package manager maintains the directory and that package contents change when the package's identity or version changes. webpack can use information such as the name and version in
package.jsonto avoid validating every file in the package.Reading package metadata is different from hashing the entire package. The optimization depends on that metadata representing the package contents; it does not prove that every file is unchanged.
See the webpack Snapshot documentation for configuration details.
Versioning Computation Inputs: Etags
Snapshots validate filesystem inputs, while etags can represent versions of computation inputs. A code-generation example makes the distinction more concrete.
The following TypeScript examples illustrate cache design. Definitions of types such as
ModuleandConfigare omitted.Caching
moduleCodegen()requires both a cache key and an invalidation strategy.Approach 1: Compare Argument References
Assume
cacheis a tuple cache that compares argument references, rather than a regular JavaScriptMap:This has two problems:
module.buildInfoor other internal state changes, potentially producing an incorrect cache hit.Object identity therefore cannot simply be treated as a content version.
Approach 2: Deep Equality or a Custom Equality Relation
Complex objects do not always have useful equality semantics. Functions are a typical example:
The function reference remains unchanged, but its result depends on mutable state in the closure.
Comparison cost is another problem. Suppose a snapshot contains many paths:
Comparing these collections may involve path comparisons, hash computation, and set lookups. With sufficiently complex inputs, equality checks can approach or even exceed the cost of recomputation.
Approach 3: Represent Relevant Input Versions with an Etag
First compute the
filenamethat actually affects the output, then combine it with an etag for the module's contents:This example assumes that the module hash already covers the other inputs required for rendering.
filenameis evaluated only once, so a stateful function cannot return different values when constructing the etag and rendering the result.Etags have two core requirements.
First, account for every factor that affects the result.
Omitting an input can easily produce incorrect caching. For example, Rspack issue #14873 describes a chunk-render cache that did not include the output path. When a filename function moves a chunk into a deeper directory, relative asset URLs inside that chunk may need to change even if the chunk's content hash is unchanged.
Encoding version information directly in the key is also possible, but it can increase the cost of constructing, storing, and comparing keys. Separating a stable identifier from a changing etag makes their responsibilities clearer.
Second, when a hash serves as the version marker, encode the inputs completely and reduce collision probability.
A content hash used to determine cache validity has a different responsibility from the hash used to locate a bucket in a
HashMaporHashSet:Eqto distinguish keys after a hash collision. Assuming theHash/Eqcontract holds, collisions primarily affect performance.The following ordinary functions illustrate the difference; they are not implementations of the actual
RspackHashtrait:If a key's
Eqimplementation compares the entire string, a prefix hash can still satisfy the requirement that equal values have equal hashes when used for bucket selection. It may simply cause many collisions. Used directly to determine cache-content equality, however, it systematically misses suffix changes.Every fixed-size hash can collide. Encoding all inputs does not mathematically eliminate that possibility.
Rebuilds in Development and Production
webpack creates a new Compilation for both development rebuilds and production builds, reusing cached results that remain valid during the process.
watching.invalidate()explicitly. That public method does not require the caller to supply a list of changed files.The public arguments to
invalidate()should be distinguished from the information passed internally by the watcher. webpack's watcher provides file timing information and sets of modified and deleted paths. This information participates in updating filesystem state; it is not merely data for plugins to consume. See Watching.js.For comparison, the referenced Rspack persistent snapshot implementation computes sets of modified, deleted, and unchanged paths. This illustrates another way to organize invalidation information: restore state and compute the change sets first, then use that information in subsequent build work.
Lazy Compilation and Cache Lifetimes
Development rebuilds are not always triggered by file changes. With lazy compilation enabled, a browser request for an inactive entry can also trigger a compilation.
Consider the navigation sequence
A → B → A. Entry activation, the current ModuleGraph, and cache lifetimes are three separate concerns:Removing inactive modules from the current graph and retaining recoverable cached results can therefore be designed separately. Whether a result can be reused still depends on the cache layer, validity checks, and eviction policy.
In Next.js 16.3, Turbopack uses filesystem persistence to support memory-cache eviction and reduce memory use during long development sessions. The official examples report the following measurements after compiling 50 routes:
These measurements apply to specific projects, not to every application. See Turbopack: What's New in Next.js 16.3 for the data and mechanism.
Storage Layers: Memory, Filesystem, and GC
webpack's
cache.typesupports two main modes:type: "memory": Enable memory caching only.type: "filesystem": Enable filesystem caching, usually with a memory layer for frequently accessed entries. An L1/L2 analogy is useful for understanding the relationship.Both modes involve memory, but they use different configuration options:
cache.maxGenerationscache.maxMemoryGenerationscache.cacheUnaffectedcache.memoryCacheUnaffectedexperiments.cacheUnaffectedexperiments.cacheUnaffectedcache.maxAgeThe generation options also have special values. For example,
maxMemoryGenerations: 0in filesystem mode disables the additional memory-cache layer, so L1 is not necessarily enabled under every configuration. See WebpackOptionsApply.js for how the layers are configured, and the webpack Cache documentation for option details.Generational GC
webpack uses generations to measure how many compilation rounds an entry has gone without being accessed and evicts entries that remain inactive. This is neither a wall-clock timer nor an exact limit on memory usage in bytes.
cache.maxGenerations.cache.maxMemoryGenerations.Filesystem caching also has a separate
cache.maxAge. It limits how long entries in disk packs may remain unused. Expired entries are discarded during pack maintenance and subsequent persistence, rather than immediately rewriting individual disk entries when their age limit is reached. See PackFileCacheStrategy.js.flowchart TD Read[Cache lookup] --> L1["L1: Memory cache<br/>maxMemoryGenerations"] L1 -->|Miss| L2["L2: Disk pack cache<br/>maxAge"] L2 -->|Missing or invalid| Compute[Recompute] L2 -->|Populate L1 after a hit| L1Persistence also requires care around file replacement. If an existing pack may still be read, data in use cannot be arbitrarily overwritten or deleted. Writing temporary files and then switching file versions is one mechanism to consider when implementing such storage.
Populating L1 After an L2 Hit
On an L1 miss, the memory layer can register a
gotHandlerand continue reading from the next layer. After L2 returns a result,Cache.get()invokes the handler before completing the read, storing the result and etag in L1. Subsequent reads in the same process can then hit memory directly.This allows entries evicted from memory to be restored from disk without full recomputation. It is particularly valuable for expensive work such as module builds, and it provides a foundation for using disk caching to support memory eviction in development sessions that visit many routes.
Populating the cache and validating an entry are separate responsibilities: a result's presence on disk does not make it valid for the current inputs. Callers that require filesystem snapshots still need to validate them. Other entries must be checked against their etags or other invalidation conditions.
Any bundler implementation should distinguish between two capabilities:
Implementing the first does not automatically provide the second. Likewise, exposing a generation option does not mean that every cache entry participates in a common eviction mechanism.
Deferring Persistence with an Idle Timeout
In development, persistence should avoid occupying the HMR critical path for long periods. webpack's IdleFileCachePlugin schedules disk writes after entering an idle phase, using options such as
idleTimeout.During rapid edits, deferring writes can reduce repeated persistence work and prevent serialization from one compilation from competing with the next build for resources. The related options cover ordinary idle periods, the initial store, and storage after large changes.
This suggests a useful implementation check: if snapshot saving, serialization, or disk writes perform substantial work on the rebuild critical path, enabling persistent caching can actually slow down HMR.
Cache Lifetimes and Dependency Scope
Lifetimes
Caches in webpack, Rspack, and Turbopack can be divided into three categories based on how long their results remain reusable:
These lifetimes impose different requirements on keys, invalidation, and restoration cost. Choosing an appropriate reuse scope is an important part of a high-performance cache design.
Dependency Scope of a Computation
Another useful distinction is how much context a computation depends on:
export *.The latter two categories lead to the boundary between
cacheUnaffectedand global effects.Graph Computation: CacheUnaffected and Global Effects
Both
cache.cacheUnaffectedfor memory caching andcache.memoryCacheUnaffectedfor filesystem caching requireexperiments.cacheUnaffected. They primarily serve repeated compilations within the lifetime of one Compiler, especially development rebuilds.webpack's caching can be broadly divided into entries managed through the general cache interface and in-memory computation results retained for unaffected modules. The two mechanisms complement each other.
CacheUnaffected Versus the General Cache Interface
cache.store()cacheUnaffectedbuildInfo, references, effect propagation, chunk graph, and related stateget()Map.get()orWeakTupleMap.get()Using the filesystem backend requires identifiers that remain stable across processes. A module can use its identifier, but dependency objects and temporary IDs may not be stable across process lifetimes. Making every cache persistable adds identifier-conversion, persistence, and validation costs. See the Rust compiler's discussion of incremental persistence for related challenges.
In contrast,
cacheUnaffectedcan use the identities of live objects and their graph relationships, avoiding the need to produce a complete, persistable input summary for every graph computation.Local and Nonlocal Computations
When a result's inputs can be represented completely at low cost, the general cache interface is straightforward to use: construct an identifier, compute an etag, and validate a snapshot if needed.
For results that depend on ModuleGraph, ChunkGraph, or other computed results, generating a complete etag may be expensive.
cacheUnaffectedattempts to reuse in-memory results by establishing that the current changes have not affected the module.This does not restrict
cache.store()to local computations. The distinction is the cost of representing inputs and proving that results remain valid.For example:
module.buildprimarily depends on the module's declared build inputs. If all inputs are tracked and the loader obeys the caching contract, its result can be reused based on snapshots and other applicable conditions.export *chain can change a module's export set even when its own source is unchanged.Here,
index.jsexportsa,b,c,d, ande. Iflib3.jschanges to:then the export set of
index.jschanges even though its own source has not changed.An etag could theoretically include every dependency introduced through
export *, but computing those etags may repeatedly traverse the dependency graph. Reusing results based on which modules are affected can reduce that cost. In simplified pseudocode:Computing Affected Modules
webpack's
_computeAffectedModules()does not directly compare every module's export set. It checksbuildInforeferences, the Modules targeted by Dependencies, and how effects propagate through dependency relationships.There are two distinct stages:
Propagation from a changed dependency to referencing modules also takes the dependency's effect type into account. It does not unconditionally invalidate every module reachable through reverse edges.
For example, suppose
lib3.jsbecomes:The export names have not changed, but that alone does not establish that referencing modules are unaffected. Rebuilding the module may replace its
buildInfoobject, triggering conservative cache invalidation and propagation. A particular export-analysis result being theoretically reusable does not mean this implementation will retain its cache.ModuleMemCaches and ModuleMemCaches2
Three webpack fields are easy to confuse:
compiler.moduleMemCachesbuildInfo, reference information, and first-level cache across Compilationscompilation.moduleMemCachescompilation.moduleMemCaches2The latter two Maps belong to the current Compilation. They can be recreated for a new round while continuing to reference
WeakTupleMapcache objects from the previous round that remain valid. The diagram below omits the individual Module keys:flowchart TD Store["compiler.moduleMemCaches<br/>Module cache entries retained across Compilations"] L1["First-level memCache: WeakTupleMap<br/>Module analysis and dependency-graph queries"] Wrapper["key = memCache2<br/>references + memCache"] L2["Second-level memCache2: WeakTupleMap<br/>Module hashes and runtime requirements"] A1["Compilation A<br/>moduleMemCaches"] B1["Compilation B<br/>moduleMemCaches"] A2["Compilation A<br/>moduleMemCaches2"] B2["Compilation B<br/>moduleMemCaches2"] Store -->|item.memCache| L1 L1 --> Wrapper Wrapper --> L2 A1 --> L1 B1 -->|Reuse while valid| L1 A2 --> L2 B2 -->|Reuse after validation| L2These caches primarily hold intermediate results associated with modules. They are not a second store of module source, ASTs, or build output.
First-Level MemCache
Accessing the first-level cache involves two lookups:
Validity conditions:
buildInforeference is unchanged.Typical entries:
"noWarningsOrErrors"trueFlagDependencyExportsPlugininstance["bundleChunkGraph.blockModules", runtime][dependency, cacheStage, ...args]"memCache2"{ references, memCache }Corresponding source: diagnostic caching, export analysis, ChunkGraph construction, and Dependency queries.
Lifetime:
After modules have been built,
_computeAffectedModules()compares them with the previous round's baseline:buildInforeference or tracked dependency target: Create a new memCache.buildInfo: Remove the corresponding entry retained across Compilations.The first-level cache can therefore survive multiple Compilations until a relevant change occurs in the module, its dependency targets, or the chain of propagated effects.
Second-Level MemCache2
The second-level cache needs further validation after module and chunk IDs have been assigned during the seal phase.
Validity conditions:
Typical entries:
"moduleRuntimeRequirements-" + runtimeKeySet<RuntimeGlobals>, ornullto cache the absence of runtime requirements"moduleHash-" + runtimeKeySee the implementations of runtime-requirements caching and module-hash caching.
Lifetime:
_computeAffectedModulesWithChunkGraph()handles the following cases:How the Two Levels Differ
Suppose a module's source and dependency relationships remain unchanged, but its module ID changes from
10to15. The first-level cache may still be reused, while the second-level cache must be replaced.buildInfochangesGlobal Effects and the Limits of CacheUnaffected
webpack's
cacheUnaffectedis not an incremental switch that can be enabled independently for each stage. A useful way to understand it is to focus on changes to a module and the modules it references. That, however, does not cover the full dependency scope of every computation.Some computations also depend on consumers or on the module graph as a whole. Propagating changes only from dependencies to referencing modules cannot cover their entire invalidation set. webpack calls these broader influences global effects.
A typical example is
FlagDependencyUsagePlugin: which exports of a module are used depends on its consumers and the exports those consumers reference.The first compilation:
Then only the entry changes:
The source of
lib.jsremains unchanged, but its used exports change fromfootobar. Here, a change in the consumer affects the producer.The public reproduction for cacheUnaffected and usedExports deliberately removes webpack's guard against the incompatible configuration and forces both features on. The second compilation incorrectly reuses module hashes and generated results, producing output that differs from a fresh build.
Unmodified webpack rejects this combination, so the reproduction should not be interpreted as an error that occurs under the default configuration. See the guard in FlagDependencyUsagePlugin.
The required chain of updates is:
Updating export state without invalidating downstream caches can still produce stale code. An implementation that maintains incremental results by stage must identify what each stage changes and which later results depend on those changes. A plugin's name alone is not enough to determine the invalidation scope.
The following optimizations all encounter this boundary:
optimization.usedExportsFlagDependencyUsagePluginoptimization.mangleExportsMangleExportsPluginoptimization.concatenateModulesModuleConcatenationPluginThe other two guards appear in MangleExportsPlugin and ModuleConcatenationPlugin.
Although
cacheUnaffectedhelps development rebuilds, it does not automatically make every production optimization incremental. These webpack plugins reject combinations involving incompatible global effects. General memory and persistent caches can still provide reuse at other levels.Incremental production optimization is difficult because production builds seek global optimization, while incremental computation tries to keep the impact of a change local. The same tension appears in cross-module optimizations such as Rust LTO. See Challenges for Incremental Production Optimizations for a related discussion.
Other Cache Interfaces and Their Assumptions
Beyond
cacheUnaffectedand the general cache interface, webpack has several narrower cache options with different invalidation assumptions.Module Unsafe Cache
module.unsafeCachereuses the result of resolving a Dependency to a Module across Compilations.In the referenced default configuration, enabling the top-level cache selects modules whose
nameForCondition()path containsnode_modules. When caching is disabled, the default isfalse.falsetrue(module) => booleanThe basic process is:
Dependency D → Module Mnormally during the first compilation.D → Min a mapping keyed by the Dependency object.A hit depends on the Dependency object's identity, not the request string. A rebuild that produces a new Dependency object will not hit the mapping for the old object.
Old and new Compilations may hold the same Module instance while maintaining different ModuleGraphs. Even when different graph objects manage graph relationships, fields on the Module itself, such as
dependencies,buildInfo, and source, can still be shared mutable state. An old Compilation is therefore not a fully immutable historical snapshot.The cache is called unsafe because it uses stronger assumptions of stability to omit some invalidation checks. Changes to packages, resolution options, rules, loaders, or plugin behavior may leave stale resolution results in use. The default focuses on
node_modulesbecause third-party dependencies are usually relatively stable during a watch session.A factory can also disable this reuse with
factoryResult.cacheable = false. See the exact conditions in Compilation.js.Loader Cacheable
NormalModule.needBuild()considers module state, cacheability, snapshots, and other conditions when deciding whether to rebuild.When a loader uses additional inputs, it should declare dependencies so those inputs participate in invalidation wherever possible. If the inputs cannot be tracked reliably, or the same known inputs can still produce different results, the loader can call:
This marks that module-build result as uncacheable. Virtual modules and loaders that depend on mutable external state need particularly clear input and lifetime rules. See the Loader Interface.
Resolve Cache and Resolve Unsafe Cache
Both cache resolution, but their validity assumptions differ:
resolve.cacheresolve.unsafeCacheThe default value of
resolve.cachefollows whether the top-level cache is enabled. According to the webpack Resolve documentation,resolve.unsafeCacheis disabled by default and must be configured explicitly. The two operate at different layers. When enabling unsafe caching, check its assumptions whenever a resolution result also depends on mutable inputs outside the cache key.All reactions