Fix: Svelte Store Subscription Leak — Memory Leak from Unsubscribed Stores
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.
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.
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}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.
For related issues, see Fix: Svelte Store Not Updating and Fix: Vue Reactive Data Not Updating.
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.