Skip to content

Fix: Svelte Store Subscription Leak — Memory Leak from Unsubscribed Stores

FixDevs · (Updated: )

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 server

Why 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 $count in a .svelte template creates and destroys the subscription with the component’s lifecycle.
  • Manual subscribe() calls require manual cleanup — if you call store.subscribe() in onMount, JavaScript code, or outside a Svelte template, you’re responsible for calling the returned unsubscribe function.
  • derived stores with side effects — a derived store 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 .svelte files (components), not in .js or .ts modules
  • 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 store

Fix 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 growing

Detect 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:

  1. Open DevTools, go to Memory tab.
  2. Take a heap snapshot (Snapshot 1).
  3. Navigate between routes 10-20 times.
  4. Take another heap snapshot (Snapshot 2).
  5. Compare: filter by “Objects allocated between Snapshot 1 and Snapshot 2.”
  6. Look for Object entries 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 subscriptionget(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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles