Skip to content

Memory leak in ToastState - dismissed toasts never cleaned up #729

@helciofranco

Description

@helciofranco

It seems this package has a memory leak where dismissed toasts are never removed from the global state, causing the toasts array to grow indefinitely throughout the application's lifetime.

The Observer class in state.ts maintains a this.toasts array that accumulates every toast ever created, but never removes them when they are dismissed or closed.

The problem becomes significantly more severe when using custom JSX content.
Each toast object retains full React element trees with component instances, hooks, event handlers, and DOM references in this.toasts.

These never get garbage collected since the global array holds strong references, leading to exponentially higher memory usage compared to primitive title / description  strings.

Simple strings use ~1-5KB per toast; complex JSX can exceed 10-50KB each, causing gigabytes of leaks in long-running SPAs.
Example: Crypto Exchange.


Demo

  1. addToast only appends more items

sonner/src/state.ts

Lines 42 to 45 in 45d8940

addToast = (data: ToastT) => {
this.publish(data);
this.toasts = [...this.toasts, data];
};

  1. dismiss doesn't clean up the this.toasts array

sonner/src/state.ts

Lines 88 to 99 in 45d8940

dismiss = (id?: number | string) => {
if (id) {
this.dismissedToasts.add(id);
requestAnimationFrame(() => this.subscribers.forEach((subscriber) => subscriber({ id, dismiss: true })));
} else {
this.toasts.forEach((toast) => {
this.subscribers.forEach((subscriber) => subscriber({ id: toast.id, dismiss: true }));
});
}
return id;
};

  1. The dismissed toasts remain in this.toasts forever, only tracked separately in this.dismissedToasts Set.

  2. The component state in index.tsx properly filters toasts

setActiveToasts((toasts) => toasts.filter((t) => t.id !== toast.id));

return toasts.filter(({ id }) => id !== toastToRemove.id);

However, the global ToastState.toasts in the Observer class is never cleaned up.


Proposed Solutions

  1. Adding a maximum history size with LRU eviction
    OR
  2. Adding a clearHistory() method
    OR
  3. Adding cleanup logic to the dismiss method (this should be a little bit harder due to the animations)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions