Skip to content

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

FixDevs ·

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.

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.

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}

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.

For related issues, see Fix: Svelte Store Not Updating and Fix: Vue Reactive Data Not Updating.

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