Fix: Svelte Store Subscription Leak — Memory Leak from Unsubscribed Stores
Part of: React & Frontend Errors
Quick Answer
How to fix Svelte store subscription memory leaks — auto-subscription with $, manual unsubscribe, derived store cleanup, custom store lifecycle, and SvelteKit SSR store handling.
The Problem
A Svelte component subscribes to a store but doesn’t clean up, causing a memory leak:
// store.js
import { writable } from 'svelte/store';
export const count = writable(0);
// Component.svelte — manual subscription without cleanup
import { onMount } from 'svelte';
import { count } from './store';
let currentCount;
onMount(() => {
count.subscribe(value => {
currentCount = value;
// This subscription is NEVER unsubscribed
// Even after the component is destroyed, the callback keeps running
});
});Or a derived store creates intervals or promises that aren’t cleaned up:
const liveData = derived(sourceStore, ($source, set) => {
const interval = setInterval(() => {
fetch('/api/data').then(r => r.json()).then(set);
}, 5000);
// Missing: return () => clearInterval(interval);
// Interval keeps running after the derived store has no subscribers
});Or in SvelteKit, stores initialized on the server persist incorrectly between requests:
// WRONG — module-level store shared between all SSR requests
export const userStore = writable(null);
// All users share the same store state on the serverWhy This Happens
Svelte stores use a subscriber pattern. When you call store.subscribe(callback), the callback runs every time the store value changes. The subscribe method returns an unsubscribe function — if you don’t call it when the component is destroyed, the callback remains registered indefinitely.
This is a classic resource leak. Each time a component mounts and subscribes without cleanup, the subscriber list grows by one entry. Over a long session in a single-page application, this means hundreds or thousands of orphaned callbacks accumulating in memory. The symptoms are subtle at first — slightly higher memory usage, occasional sluggishness — but after hours of use (or rapid navigation between routes), the browser tab can consume gigabytes of RAM and eventually crash. In production, this manifests as support tickets from users reporting that your SPA “gets slow after a while” or “my browser tab freezes if I leave it open.”
Key behaviors:
- Auto-subscription (
$store) handles cleanup automatically — using$countin a.sveltetemplate creates and destroys the subscription with the component’s lifecycle. - Manual
subscribe()calls require manual cleanup — if you callstore.subscribe()inonMount, JavaScript code, or outside a Svelte template, you’re responsible for calling the returned unsubscribe function. derivedstores with side effects — aderivedstore that creates timers, WebSocket connections, or HTTP requests inside its callback must return a cleanup function as the second argument.- SvelteKit SSR — stores defined at the module level are shared across all server-side requests. In SSR, each request must get its own store instance.
The per-user blast radius is what makes this insidious. Unlike a server-side memory leak that affects all users simultaneously, a store subscription leak only affects users who navigate frequently within the SPA. Power users who keep the app open all day are the ones who hit it, and they’re often the hardest users to lose.
Fix 1: Use Auto-Subscription in Templates
The $ prefix in Svelte templates automatically handles subscribe and unsubscribe:
<!-- CORRECT — auto-subscription -->
<script>
import { count, userStore } from './stores';
// No manual subscribe needed
</script>
<!-- Svelte automatically subscribes when component mounts
and unsubscribes when component is destroyed -->
<p>Count: {$count}</p>
<p>User: {$userStore?.name}</p>
<button on:click={() => count.update(n => n + 1)}>+</button><!-- Auto-subscription also works in reactive statements -->
<script>
import { items } from './stores';
// $items updates whenever the store changes — no manual subscribe
$: filteredItems = $items.filter(item => item.active);
$: totalCount = $items.length;
</script>
{#each filteredItems as item}
<p>{item.name}</p>
{/each}Auto-subscription limitations:
- Only works in
.sveltefiles (components), not in.jsor.tsmodules - Only works at the top level of the
<script>block — not inside functions or callbacks
Fix 2: Properly Unsubscribe Manual Subscriptions
When you need manual subscribe() calls, always save and call the unsubscribe function:
<script>
import { onMount, onDestroy } from 'svelte';
import { count } from './stores';
let currentCount = 0;
let unsubscribe;
onMount(() => {
// WRONG — no cleanup
// count.subscribe(value => { currentCount = value; });
// CORRECT — save the unsubscribe function
unsubscribe = count.subscribe(value => {
currentCount = value;
console.log('Count updated:', value);
});
});
onDestroy(() => {
unsubscribe?.(); // Unsubscribe when component is destroyed
});
</script>
<p>Count: {currentCount}</p>Cleaner pattern — subscribe at top level of script:
<script>
import { count } from './stores';
import { onDestroy } from 'svelte';
let currentCount = 0;
// Subscribe at top-level (not in onMount)
const unsubscribe = count.subscribe(value => {
currentCount = value;
});
// Clean up — called automatically when component is destroyed
onDestroy(unsubscribe);
</script>Multiple subscriptions — clean up all:
<script>
import { onDestroy } from 'svelte';
import { countStore, userStore, themeStore } from './stores';
let count, user, theme;
const unsubscribers = [
countStore.subscribe(v => count = v),
userStore.subscribe(v => user = v),
themeStore.subscribe(v => theme = v),
];
onDestroy(() => {
unsubscribers.forEach(unsub => unsub());
});
</script>Fix 3: Add Cleanup to derived Stores
When a derived store has side effects (timers, fetch calls, WebSocket), return a cleanup function:
// stores.js
import { derived, writable } from 'svelte/store';
export const refreshInterval = writable(5000);
// WRONG — interval never cleared
export const liveData = derived(sourceStore, ($source, set) => {
const interval = setInterval(async () => {
const data = await fetch('/api/data').then(r => r.json());
set(data);
}, 5000);
// Missing cleanup!
});
// CORRECT — return cleanup function as second set argument
export const liveData = derived(refreshInterval, ($interval, set) => {
let data = null;
async function fetchData() {
try {
data = await fetch('/api/live-data').then(r => r.json());
set(data);
} catch (err) {
console.error('Fetch failed:', err);
}
}
fetchData(); // Initial fetch
const interval = setInterval(fetchData, $interval);
// Return cleanup function — called when last subscriber unsubscribes
return () => {
clearInterval(interval);
};
});WebSocket-based derived store:
// stores.js
import { derived, writable } from 'svelte/store';
export const wsUrl = writable('wss://api.example.com/live');
export const liveMessages = derived(wsUrl, ($url, set) => {
const ws = new WebSocket($url);
set([]); // Initial empty state
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
set(prev => [...prev, message]);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
// Cleanup — close WebSocket when store has no subscribers
return () => {
ws.close();
};
});Fix 4: Build Custom Stores with Lifecycle Management
For stores that manage their own lifecycle (start/stop behavior based on subscriber count):
// stores.js
import { readable, writable, get } from 'svelte/store';
// readable() with a start function — runs when first subscriber appears
// Stop function returned from start — runs when last subscriber unsubscribes
export const clock = readable(new Date(), (set) => {
// Start: runs when first component subscribes
const interval = setInterval(() => {
set(new Date());
}, 1000);
console.log('Clock started');
// Stop: runs when last component unsubscribes
return () => {
clearInterval(interval);
console.log('Clock stopped');
};
});
// Usage in component — clock only ticks when something is subscribed
// <p>Time: {$clock.toLocaleTimeString()}</p>Custom store with public interface:
function createCounter(initial = 0) {
const { subscribe, set, update } = writable(initial);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(initial),
// Expose current value without subscribing
get value() { return get({ subscribe }); },
};
}
export const counter = createCounter(0);
// In component:
// $counter — subscribes automatically
// counter.increment() — updates the storeFix 5: Fix SvelteKit SSR Store Leaks
In SvelteKit, module-level stores are shared across all server-side requests. Use setContext/getContext for per-request stores:
// WRONG — shared across all SSR requests
// src/lib/stores.js
import { writable } from 'svelte/store';
export const userStore = writable(null); // Shared between ALL server requests!
// CORRECT — context-based stores for SSR safety
// src/routes/+layout.svelte
<script>
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
// Create a new store for each request/page load
const userStore = writable(null);
setContext('user', userStore);
</script><!-- Child component — gets the per-request store from context -->
<script>
import { getContext } from 'svelte';
const userStore = getContext('user');
</script>
<p>User: {$userStore?.name ?? 'Guest'}</p>SvelteKit’s built-in page store — safe for SSR:
<script>
import { page } from '$app/stores';
// $page is automatically managed by SvelteKit per request
// Safe to use in both SSR and client-side
</script>
<p>Current path: {$page.url.pathname}</p>
<p>User: {$page.data.user?.name}</p>Use load functions for SSR data instead of stores:
// src/routes/+page.server.js
export async function load({ locals }) {
// Fetch data server-side — no store needed
const user = locals.user;
const posts = await db.getPosts();
return { user, posts };
// Data is accessible in the template as $page.data.user and $page.data.posts
}Fix 6: Debug Store Subscription Leaks
Detect subscription leaks by tracking subscriber counts:
// Debugging wrapper that logs subscriber count changes
function debugStore(store, name) {
let subscriberCount = 0;
let originalSubscribe = store.subscribe;
store.subscribe = function(callback) {
subscriberCount++;
console.log(`[${name}] Subscriber added. Total: ${subscriberCount}`);
const unsubscribe = originalSubscribe.call(this, callback);
return () => {
subscriberCount--;
console.log(`[${name}] Subscriber removed. Total: ${subscriberCount}`);
unsubscribe();
};
};
return store;
}
export const count = debugStore(writable(0), 'count');
// Logs subscriber adds/removes — check if count keeps growingDetect memory leaks in development:
// Track active subscriptions
const activeSubscriptions = new Set();
function trackSubscription(unsubscribe, label) {
const wrapped = () => {
activeSubscriptions.delete(wrapped);
unsubscribe();
};
activeSubscriptions.add({ unsubscribe: wrapped, label, stack: new Error().stack });
return wrapped;
}
// In components during debugging:
const unsub = trackSubscription(
myStore.subscribe(v => {}),
'MyComponent > myStore'
);
// Later check:
console.log('Active subscriptions:', activeSubscriptions.size);Fix 7: Store Patterns for Complex State
For applications with complex state, organize stores cleanly:
// stores/auth.js — organized auth store
import { writable, derived, get } from 'svelte/store';
function createAuthStore() {
const user = writable(null);
const token = writable(localStorage.getItem('token'));
const isAuthenticated = derived(
[user, token],
([$user, $token]) => $user !== null && $token !== null
);
async function login(credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) throw new Error('Login failed');
const { user: userData, token: tokenValue } = await response.json();
user.set(userData);
token.set(tokenValue);
localStorage.setItem('token', tokenValue);
}
function logout() {
user.set(null);
token.set(null);
localStorage.removeItem('token');
}
return {
user: { subscribe: user.subscribe }, // Read-only access to stores
token: { subscribe: token.subscribe },
isAuthenticated: { subscribe: isAuthenticated.subscribe },
login,
logout,
get currentUser() { return get(user); },
};
}
export const auth = createAuthStore();<!-- Usage -->
<script>
import { auth } from './stores/auth';
</script>
{#if $auth.isAuthenticated}
<p>Welcome, {$auth.user?.name}!</p>
<button on:click={auth.logout}>Logout</button>
{:else}
<button on:click={() => auth.login({ email, password })}>Login</button>
{/if}Fix 8: Monitor Memory in Production SPAs
Store subscription leaks are silent in production unless you actively monitor for them. The symptom is gradual memory growth over time — not a sudden spike — which makes it hard to catch without instrumentation.
Use the Performance API to track heap size:
// memory-monitor.js — include in your SPA entry point
function reportMemory() {
if (!performance.memory) return; // Chrome only
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } = performance.memory;
console.log(
`Heap: ${mb(usedJSHeapSize)}MB / ${mb(totalJSHeapSize)}MB (limit: ${mb(jsHeapSizeLimit)}MB)`
);
// Alert if heap exceeds a threshold (e.g., 200MB for a typical SPA)
if (usedJSHeapSize > 200 * 1024 * 1024) {
console.warn('High memory usage detected — possible subscription leak');
// Send to your RUM/monitoring service
}
}
// Check every 60 seconds
setInterval(reportMemory, 60000);RUM (Real User Monitoring) integration:
If you use a service like Datadog, New Relic, or Sentry, send periodic memory snapshots as custom metrics. Look for a pattern where memory climbs linearly with navigation count — that is the signature of a subscription leak. A stable app’s memory graph should be sawtooth-shaped (climbs, then drops during garbage collection), not a steady upward slope.
Chrome DevTools heap snapshot comparison:
- Open DevTools, go to Memory tab.
- Take a heap snapshot (Snapshot 1).
- Navigate between routes 10-20 times.
- Take another heap snapshot (Snapshot 2).
- Compare: filter by “Objects allocated between Snapshot 1 and Snapshot 2.”
- Look for
Objectentries with subscriber-related labels or growing arrays of callbacks.
If the retained size keeps growing with each navigation cycle, you have a leak. The comparison view shows exactly which objects are not being garbage collected.
Still Not Working?
get() without subscription — get(store) reads the current value synchronously without subscribing. It’s safe for one-time reads but doesn’t track changes. Use auto-subscription ($store) for reactive updates.
Stores in <script module> — code in <script module context="module"> runs once per module import (not per component). Subscribing in module-level code doesn’t have a component lifecycle to clean up with. Move subscriptions into <script> or use auto-subscription.
Store updates not triggering re-render — if you mutate an object or array inside a store without calling set() or update(), Svelte may not detect the change. Always replace the value: store.update(arr => [...arr, newItem]) instead of mutating in place.
Derived store not recalculating — a derived store only recalculates when its dependencies (the stores passed as the first argument) change. If you read a global variable or Date.now() inside the derived callback, those aren’t reactive dependencies — only store subscriptions are.
Subscription leak in onMount async callback — if onMount returns an async function, the returned promise is not used as a cleanup function. Svelte only calls synchronous return values from onMount as cleanup. Place your unsubscribe call in a separate onDestroy instead.
Event listeners inside store callbacks — if a store subscription callback adds DOM event listeners (e.g., window.addEventListener), those listeners persist after the component is destroyed even if you unsubscribe from the store. Remove event listeners explicitly in the unsubscribe/onDestroy handler.
For related issues, see Fix: Svelte Store Not Updating, Fix: Vue Reactive Data Not Updating, Fix: Angular RxJS Memory Leak, and Fix: React useEffect Infinite Loop.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Paraglide Not Working — Messages Not Loading, Compiler Errors, or Framework Integration Issues
How to fix Paraglide.js i18n issues — message compilation, type-safe translations, SvelteKit and Next.js integration, language switching, and message extraction from existing code.
Fix: Svelte 5 Runes Not Working — $state Not Reactive, $derived Not Updating, or $effect Running Twice
How to fix Svelte 5 Runes issues — $state and $state.raw reactivity, $derived computations, $effect lifecycle, $props and $bindable, migration from Svelte 4 stores, and component patterns.
Fix: SvelteKit Not Working — load Function Errors, Form Actions Failing, or SSR Data Not Available
How to fix SvelteKit issues — load function data flow, +page.server.ts vs +page.ts, form actions with use:enhance, hooks.server.ts, SSR vs CSR mode, and common routing mistakes.
Fix: WebAssembly (WASM) Not Working — Module Fails to Load, Memory Error, or JS Interop Broken
How to fix WebAssembly issues — instantiateStreaming vs instantiate, CORS for WASM files, linear memory limits, wasm-bindgen JS interop, imports/exports mismatch, and WASM in bundlers.