Pinia & Vuex Store Memory Retention
If your Vue reactivity & memory audit inside framework-specific memory optimization keeps flagging one culprit, it is usually this: a Pinia or Vuex store accumulates entries — cached API responses keyed by id, per-route form drafts, subscription history — and nothing ever removes them, so the store’s retained size climbs for the lifetime of the tab.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Heap grows per route, never drops | Cache appended, never evicted | Diff two heap snapshots |
| Memory climbs after form submits | $subscribe closes over stale state |
Log active subscriptions |
| Memory stays high after reset | Old state still referenced | Search retainers in Comparison |
| Modules pile up in Vue pane | Register with no unregister call | Grep registerModule call sites |
Root Cause
Pinia and Vuex both keep their state in a single reactive object for the lifetime of the store instance, and that instance is normally created once and never destroyed for as long as the app is mounted. This is fine for state that has a natural, bounded shape — a currentUser object, a theme string — but it becomes a leak vector the moment a piece of state is a collection that only ever grows: a cache object keyed by every product id the user has viewed, an array of every search query issued this session, or a map of every WebSocket message received. Each insert makes the reactive proxy wrap a new nested object, and because Vue’s reactivity system (and Vuex’s mutation tracking) hold strong references from parent to child, nothing in that chain is eligible for collection until you explicitly delete the key — this is the practical, everyday face of reference counting vs tracing GC: the tracing collector can only reclaim what has become unreachable, and an ever-growing store keeps everything reachable by design.
Two secondary mechanisms compound the problem. First, store.$subscribe(callback) returns an unsubscribe function that is easy to forget to call; if the callback closes over component-local variables or DOM references, every subscription you never tear down pins those closures in memory alongside the store, one per component mount. Second, dynamic module registration (store.registerModule(name, module) in Vuex, or lazily-created stores in Pinia) is commonly paired with route-based lazy loading, but the matching unregisterModule(name) call is far easier to skip, especially inside error paths or early returns — the module keeps its full state tree resident even after the feature that registered it has unmounted.
Step-by-Step Fix
-
Capture a baseline with two heap snapshots. Open DevTools → Memory → Heap Snapshot, take snapshot 1, perform the suspected leaking action 10 times (route changes, form submits, or WebSocket messages), then take snapshot 2. Switch to the Comparison view and filter the class list by your store’s constructor name (for Pinia it usually shows as
Objectwrapping a_customPropertiesfield; search by a distinctive key name instead). Expected output: the Comparison view shows a positive# DeltaandAlloc. Sizefor the object holding your cache, confirming growth rather than steady-state noise. -
Identify the exact field that is growing. In the retained snapshot, expand the store instance and look at each top-level state key’s
Retained Size. The field with a retained size that scales linearly with the number of actions performed (not with the number of unique users or components) is your unbounded collection. Expected output: one field — for examplestate.productCache— accounts for over 80% of the store’s total retained size delta between the two snapshots. -
Add an eviction policy to that field. Wrap inserts through a single mutation/action so every write path enforces the cap (see the
useBoundedCacheexample below) rather than letting components write to the raw object directly. Expected output: re-running the same 10 actions now shows the field’s key count plateau at the configured cap instead of climbing. -
Audit every
$subscribeandregisterModule/unregisterModulecall site. Rungrep -rn "\$subscribe(" src/andgrep -rn "registerModule(" src/and confirm each has a matching teardown inonUnmountedor a route guard. Expected output: every$subscribecall site has a stored return value invoked on unmount; everyregisterModulehas a correspondingunregisterModuleon the reverse navigation path. -
Re-run the snapshot diff to confirm the fix. Repeat step 1’s 10-action sequence against the patched code and compare snapshot 3 against snapshot 4. Expected output: the store’s retained size delta between the two new snapshots is within noise (a few KB), not the multi-MB growth seen in step 1.
Command & Code Reference
A bounded cache helper for Pinia that caps entries and evicts the least-recently-used one on overflow:
// bounded-cache.js — LRU-style cap for a Pinia store field
export function useBoundedCache(maxEntries = 50) {
// Map preserves insertion order, which we
// use as a cheap recency signal (re-set on read).
const cache = new Map();
function get(id) {
if (!cache.has(id)) return undefined;
const value = cache.get(id);
// Re-insert to mark this id as most recently used.
cache.delete(id);
cache.set(id, value);
return value;
}
function set(id, value) {
if (cache.has(id)) cache.delete(id);
cache.set(id, value);
// Evict the oldest entry once we exceed the cap.
if (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey); // drops the last strong ref
}
}
return { cache, get, set };
}
Correct subscribe/unsubscribe and module lifecycle inside a Vue component:
// StoreLifecycle.vue (script setup) — clean teardown
import { onUnmounted } from "vue";
import { useProductStore } from "@/stores/product";
const store = useProductStore();
// $subscribe returns an unsubscribe function — keep it.
const unsubscribe = store.$subscribe((mutation, state) => {
// Avoid closing over local DOM refs here; that
// keeps this component's DOM tree alive too.
console.log("product store mutated:", mutation.type);
});
onUnmounted(() => {
unsubscribe(); // detach before the component is gone
});
Per-entity metadata that should die with the entity, using a WeakMap keyed by object identity rather than an id string:
// entity-metadata.js — GC-friendly side table
const metadataByEntity = new WeakMap();
// Key must be an object (the entity itself),
// never a primitive id — WeakMap requires that.
export function tagEntity(entityObject, meta) {
metadataByEntity.set(entityObject, meta);
}
export function readMeta(entityObject) {
return metadataByEntity.get(entityObject);
// No delete() needed: once entityObject is
// dropped from the store, this entry is collectible.
}
Verification & Regression Prevention
Set a concrete budget before you ship the fix: pick a maximum retained size for the store (for example 8 MB after 50 route changes) and record it as your target. Confirm it with a fresh heap snapshots comparison after a scripted session, not a single manual click-through — the Comparison view’s Alloc. Size delta between the first and last snapshot is the number to track over time.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Cache field grows unbounded | No eviction on insert | Add capped Map, LRU evict | Retained size flat (±0.5 MB) |
| Subscriptions rise on reload | Missing unsubscribe() |
Call return value on unmount | Subs ≤ mounted components |
| Module count never drops | Missing unregisterModule() |
Pair register/unregister | Modules match active routes |
| Metadata grows with views | Map keyed by string id | Switch to WeakMap by entity |
Entries collected with entity |
For ongoing protection, add a lightweight assertion to your test suite or a CI step that fails the build if a store’s serialized state size exceeds a threshold after a scripted interaction sequence (for example, expect(JSON.stringify(store.$state).length).toBeLessThan(200_000) after 50 simulated cache inserts). Pair that with an ESLint custom rule or a simple grep -c "registerModule(" src | diff check against unregisterModule( call counts in your pre-commit hook, so an unmatched register/unregister pair fails fast instead of surfacing as a slow memory creep weeks later in production. If you also profile the surrounding component tree, cross-reference against detached DOM nodes and memory retention since a leaked subscription closure frequently pins a detached component alongside the store entry.
Frequently Asked Questions
Does calling a Pinia store’s $reset() free the memory it used?
Only if nothing else still references the old state object. $reset() replaces the store’s reactive state with a fresh copy of the initial state, but if a component, a closure, or another store cached a direct reference to the previous state object, that reference keeps the old data alive on the heap even though the store itself has moved on. Confirm with a heap snapshot comparison rather than assuming reset means freed.
Why does unregistering a dynamic Vuex module not shrink memory immediately?
unregisterModule() removes the module from the store tree and stops it receiving mutations, but the JavaScript engine only reclaims the underlying memory during the next garbage collection cycle, and only if no other code (a subscription callback, a memoised selector, a Vue Router afterEach hook) still holds a reference into that module’s state. Force a manual GC pass in DevTools after unregistering to check the true retained size, not the count right after the call.
Is a WeakMap always the right choice for per-entity metadata in a store?
A WeakMap is right when the metadata’s lifetime should be tied to an object you do not otherwise control, such as a DOM node or a component instance, and when you never need to enumerate the entries. It is the wrong choice when the key is a primitive id (a WeakMap requires an object key) or when you need to iterate all entries for a UI list — in those cases use a plain Map with an explicit eviction policy instead.
Related
- Vue reactivity & memory management — the parent guide covering Vue-specific retention patterns.
- Preventing memory leaks in Vue watchers and computed — a related fix for reactive dependency chains.
- Reference counting vs tracing GC algorithms — the underlying garbage collection mechanics referenced above.
- Interpreting heap snapshots for memory analysis — the main section for the snapshot workflow used in this fix.
- Detached DOM nodes and memory retention — a companion leak pattern often found alongside store retention.