Svelte Store Subscription Leaks
A Svelte app’s memory grows as users open and close panels, and heap snapshots show closures from destroyed components still attached to a writable store’s subscriber list — or a custom store keeps polling an API although no component uses it any more. This guide from Svelte and SolidJS Memory Management, part of Framework-Specific Memory Optimization, covers how Svelte stores track subscribers, which subscription styles clean up automatically, and how to write custom stores whose resources stop when the last subscriber leaves.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Store subscribers grow with each component mount | store.subscribe() in <script> without calling the returned unsubscribe |
Use $store or unsubscribe on destroy |
One subscriber per mounted component |
| Destroyed components retained via store | Subscriber closure captured component state | Unsubscribe; avoid capturing large state | Component collectable |
| Custom store keeps polling with no users | start function never receives a stop call because a subscriber leaked |
Fix the leaked subscriber; return a stop function from start |
Polling stops with last subscriber |
derived stores created per component pile up |
Derived created in component, subscribed manually, never released | Create derived stores at module level or use $derived |
No per-mount store instances |
| Module code subscribes forever | Subscription in a plain .js module |
Scope to a lifecycle; unsubscribe on teardown | Bounded subscribers |
Root Cause: Subscribers Keep Stores Running, and Stores Keep Subscribers Alive
A Svelte store is any object with a subscribe(callback) method that returns an unsubscribe function — the store contract. The built-in writable, readable and derived stores keep a set of subscriber callbacks. Two lifetimes follow from that. The store retains every subscriber callback until it is unsubscribed, and each callback is usually a closure over a component’s state, so a leaked subscription keeps a destroyed component’s state (and, via bound elements, its DOM) alive — the familiar retention path from module-level caches and global singleton leaks, with the store as the module-level root. And subscribers keep the store’s resources running: readable and writable accept a start function that is called when the first subscriber arrives and may return a stop function called when the last subscriber leaves. That is how stores wrap intervals, websockets or event sources lazily — and why one leaked subscriber keeps a poller running forever.
Svelte gives you an automatic path: in a component, referencing $store makes the compiler subscribe when the component initialises and unsubscribe when it is destroyed. In Svelte 5, the same $store syntax works inside runes-mode components. The manual path — calling store.subscribe(...) in the component’s script or, worse, in a plain JavaScript module — returns an unsubscribe function you must call yourself, typically in onDestroy or in an $effect teardown, as covered in Svelte 5 effect teardown and runes.
Derived stores add a subtle variant. derived(source, fn) subscribes to its source only while it has subscribers of its own. Creating a new derived store inside every component and subscribing to it manually creates a chain: component → derived → source. If the manual subscription leaks, so does the derived store and its subscription to the source.
Step-by-Step Fix
- Count subscribers during development. Wrap stores with a counting helper (see code) or log from the
start/stopfunctions. Verification: mounting and destroying the component leaves the count where it started. - Find manual subscriptions. Search components and modules for
.subscribe(. Verification: you have a list of manual subscription sites. - Switch components to
$store. Replacestore.subscribe((v) => (value = v))with direct$storereferences in markup and script. Verification: the manual subscription disappears and values still update. - Unsubscribe where manual subscription is needed. For imperative integrations (charts, canvas), keep the returned function and call it in
onDestroyor in an$effectteardown. Verification: destroying the component decrements the subscriber count. - Make custom stores symmetric. Ensure every
startfunction returns astopthat clears intervals, closes sockets and removes listeners. Verification: when the last subscriber leaves, the resource stops (the network panel shows polling end). - Move
derivedcreation to module level. Define derived stores once, alongside their sources, rather than per component. Verification: heap snapshots show a fixed number of derived store objects.
Command and Code Reference
Use case: a custom store whose resource stops when unused.
// stores/prices.js
import { readable } from 'svelte/store';
export const prices = readable({}, (set) => {
// start: called when the first subscriber arrives
const load = async () => set(await (await fetch('/api/prices')).json());
load();
const timer = setInterval(load, 10_000);
return () => clearInterval(timer); // stop: called when the last subscriber leaves
});
Use case: automatic subscription in a component.
<!-- PricePanel.svelte -->
<script>
import { prices } from './stores/prices.js';
export let symbol;
</script>
<!-- $prices subscribes on init and unsubscribes on destroy -->
<p>{symbol}: {$prices[symbol] ?? '…'}</p>
Use case: manual subscription for an imperative chart, with teardown.
<script>
import { onMount, onDestroy } from 'svelte';
import { prices } from './stores/prices.js';
let canvas;
let chart;
let unsubscribe = () => {};
onMount(() => {
chart = createChart(canvas);
unsubscribe = prices.subscribe((p) => chart.update(p)); // manual: we own it
});
onDestroy(() => {
unsubscribe(); // release the subscription
chart?.destroy();
});
</script>
<canvas bind:this={canvas}></canvas>
Use case: count subscribers in development.
export function counted(store, name) {
let n = 0;
return {
subscribe(fn) {
n++; console.debug(`[store] ${name} subscribers: ${n}`);
const unsub = store.subscribe(fn);
return () => { n--; console.debug(`[store] ${name} subscribers: ${n}`); unsub(); };
},
};
}
Verification and Regression Prevention
Verify with the mount/destroy cycle: the subscriber count returns to zero when no component uses the store, custom stores’ stop runs (resources like polling end), and heap snapshots show no closures from destroyed components on the store. Also check module-level code: subscriptions created outside components must be tied to an explicit lifecycle or they will never end.
Prevent regressions with a lint rule or review guideline that flags .subscribe( in .svelte files unless paired with onDestroy or an effect teardown, and use the counting wrapper in development builds of stores that manage expensive resources. Component-level retention in general is covered in Svelte and SolidJS memory management.
Edge Cases and Gotchas
get(store) subscribes briefly
The get() helper subscribes and immediately unsubscribes to read a value. It does not leak, but for stores with expensive start functions it triggers start/stop on every call. Avoid it in hot paths.
Stores in context
Passing stores through context is fine; the consuming component subscribes with $store and cleans up. Problems appear only if a consumer copies the store into a module-level structure.
Svelte 5 and stores
Svelte 5 keeps the store contract and $store syntax, while encouraging $state in .svelte.js modules for shared state. Shared $state objects are plain reactive state with no subscriber list of their own; effects reading them are owned by components and torn down with them.
Async start functions
If start launches an async request, make sure stop can cancel it or ignore its result; otherwise a response arriving after stop calls set on a store nobody is listening to, which is harmless but may restart work you intended to end.
Frequently Asked Questions
Does the $store syntax unsubscribe automatically?
Yes. In components, referencing $store makes Svelte subscribe during initialisation and unsubscribe when the component is destroyed. Leaks come from manual store.subscribe() calls whose unsubscribe function is never called.
How do I clean up a manual store subscription?
Keep the function returned by subscribe and call it in onDestroy, or return it from an $effect so it runs on teardown. In plain modules, tie it to whatever lifecycle created it and call it when that ends.
Why does my custom store keep polling after the page changes?
Its stop function only runs when the last subscriber unsubscribes. If any subscription leaked, the count never reaches zero. Fix the leaked subscriber, and make sure start returns a stop that clears the interval.
Should derived stores be created inside components?
Generally no. Define them once at module level next to their sources, where they subscribe lazily and release their sources when unused. Creating them per component adds instances and, with manual subscriptions, more ways to leak.
Do writable stores need a stop function?
Only if their start function creates resources. A plain writable(initial) holds a value and a subscriber set, which cost nothing once unsubscribed. Stores that start polling, sockets or listeners when subscribed must return a stop that undoes all of it, or they keep working after the last subscriber leaves.
Can I see store subscribers in a heap snapshot?
Yes. Select the store’s internal subscriber collection in the snapshot’s Containment view or follow retainers from a component closure; the entries are the callback closures. A count that grows with component mounts is the leak signature.
Related
- Svelte and SolidJS Memory Management — the parent topic
- Svelte 5 Effect Teardown and Runes — teardown for side effects in runes mode
- Zustand and MobX Subscription Leaks — the same pattern in React state libraries
- Framework-Specific Memory Optimization — the section overview