Angular Signals: effect() Cleanup and Memory

The team moved from RxJS subscriptions to signals expecting memory problems to disappear, yet a dashboard still grows with every visit: effects created in services keep running, timers started inside effects stack up, and a signal in a root store holds the last 30 MB dataset forever. This guide from Angular RxJS Subscription Memory Leaks, in Framework-Specific Memory Optimization, explains how Angular ties effects to their injection context, where that automatic cleanup does not apply, and how to write effects and signal-based state that release memory.

Symptom Root Cause Immediate Action Measurable Impact
Effect keeps running after its component is destroyed Created outside an injection context with a long-lived injector Create in the component’s injection context, or destroy() the EffectRef Effect stops with its owner
Timers/listeners multiply each time an effect re-runs Side effects started in the effect body without cleanup Register teardown with the onCleanup callback One active timer per effect
toSignal keeps an observable subscribed forever Used with manualCleanup: true or a root injector Use the component injector; avoid manual cleanup unless you unsubscribe Subscription ends with the component
Root signal store holds huge last value Signals retain their current value indefinitely Reset to null on page exit; keep large data in scoped stores Payload released
Computed chains retain big intermediate arrays Each computed caches its latest value Derive lazily, avoid caching large intermediates Fewer large cached values

Root Cause: Effects Are Owned by Their Injector’s DestroyRef

A signal holds a value and notifies dependents when it changes. A computed caches a derived value and recomputes lazily when its sources change. An effect() runs a function whenever the signals it read change. For memory, three facts matter.

Effects have an owner. When you call effect() in an injection context — a component or directive constructor, a field initialiser, a service constructor — Angular registers it with that context’s DestroyRef. When the component or directive is destroyed, the effect is destroyed with it. An effect created inside a root service’s constructor, however, is owned by the root injector and lives for the whole app. An effect created with an explicit injector option lives as long as that injector. And an effect created outside any injection context requires an explicit injector — easy to satisfy by passing the root injector, which silently makes the effect permanent. Every running effect keeps its closure and the signals it reads alive.

Effects re-run, so their side effects must be undone each time. The effect function receives an onCleanup callback. Anything the body starts — a timer, a DOM listener, a request, a subscription — must be stopped in onCleanup, which runs before the next execution and when the effect is destroyed. Starting an interval in an effect without onCleanup leaves one extra interval every time the tracked signal changes, which is the signals-era version of the timer leak in timer and interval leaks in long-running pages.

Signals and computeds retain their current values. A signal in a root-provided store holds its latest value for the life of the app; a computed holds its latest derived value while anything reads it. Storing a large dataset in a long-lived signal, or deriving large intermediate arrays in chains of computeds, keeps that memory alive as long as the owning store does — the same scoping question discussed in Angular root services and injector-scoped memory. toSignal() bridges observables into signals by subscribing; it unsubscribes when its injection context is destroyed, unless you opted into manualCleanup.

Effect ownership and onCleanup Left: effect created in a component constructor is registered with the component's DestroyRef and destroyed when the component is destroyed. Middle: effect created in a root service constructor, or with the root injector, lives for the entire app. Right: within one effect, each run's side effects are undone by onCleanup before the next run and once more when the effect is destroyed. Component constructor effect() registered with component DestroyRef destroyed with the component Root service / root injector effect() registered with root DestroyRef runs for the whole app Inside one effect run 1: start timer onCleanup: clear timer run 2: start timer destroy: onCleanup again never more than one timer

Step-by-Step Fix

  1. Inventory effects and their owners. Search for effect( and note where each is created: component, directive, root service, or with an explicit injector. Verification: you know which effects outlive the views that need them.
  2. Move view-specific effects into components. Create effects in the constructor or field initialisers of the component that needs them, so they are destroyed with it. Verification: after navigating away, the effect’s side effects stop (a counter or log confirms).
  3. Destroy effects you create manually. When an effect must be created with an explicit injector or conditionally, keep its EffectRef and call destroy() when done. Verification: the effect no longer runs after destroy().
  4. Use onCleanup for side effects. Stop timers, remove listeners, abort requests and unsubscribe within the onCleanup callback. Verification: changing the tracked signal ten times leaves exactly one active timer or listener.
  5. Scope large signal state. Keep large datasets in component-provided stores, or reset root signals to null when leaving the page. Verification: heap snapshots after navigation no longer contain old datasets.
  6. Check toSignal usage. Avoid manualCleanup: true unless you have a clear teardown; call toSignal in the component’s injection context. Verification: the underlying observable’s subscription count drops when the component is destroyed.
Intervals started in an effect as a signal changes An effect that starts polling with setInterval whenever a filter signal changes, without onCleanup, accumulates one interval per change, reaching 20 after 20 changes. With onCleanup clearing the previous interval, there is always exactly one. 20 0 no onCleanup: +1 interval per change onCleanup clears the previous interval filter signal changes (0 → 20)

Command and Code Reference

Use case: polling driven by a signal, with correct cleanup.

@Component({
  selector: 'app-orders',
  template: `<app-order-table [orders]="orders()" />`,
})
export class OrdersComponent {
  private readonly api = inject(OrdersApi);
  readonly filter = input.required<string>();
  readonly orders = signal<Order[]>([]);

  constructor() {
    // Created in the constructor: destroyed automatically with the component
    effect((onCleanup) => {
      const f = this.filter();                       // tracked signal
      const load = () => this.api.list(f).then((o) => this.orders.set(o));
      load();
      const id = setInterval(load, 15_000);
      onCleanup(() => clearInterval(id));            // before re-run and on destroy
    });
  }
}

Use case: an effect that must be created later, with explicit lifetime.

export class ChartHostComponent {
  private readonly injector = inject(Injector);
  private chartEffect?: EffectRef;

  enableLiveUpdates(source: Signal<number[]>) {
    this.chartEffect?.destroy();                     // never stack effects
    this.chartEffect = effect(() => this.chart.update(source()), {
      injector: this.injector,                       // the component's injector, not root
    });
  }

  disableLiveUpdates() {
    this.chartEffect?.destroy();
    this.chartEffect = undefined;
  }
}

Use case: bridging an observable without leaking its subscription.

export class TickerComponent {
  // Subscribes now, unsubscribes when this component is destroyed
  readonly price = toSignal(inject(PriceFeed).price$('ACME'), { initialValue: null });
}

Verification and Regression Prevention

Verify by exercising the view repeatedly — navigate to it and away, change its inputs many times — and checking three things: side effects (intervals, listeners, requests) never exceed one per live effect; effects created for a destroyed component stop running; and heap snapshots after navigation contain no stale datasets held by signals. A development-only counter incremented in effect bodies and decremented in onCleanup makes the first two checks trivial.

For prevention, treat effect() outside component or directive constructors as needing review, require onCleanup for any effect that starts timers, listeners or requests, and keep large signal state in component-scoped stores. Where effects synchronise with external libraries, pair them with the library’s own disposal — for chart instances, see destroying chart instances in Chart.js and ECharts.

Live side effects across navigations Navigating to a view and away repeatedly, effects that start intervals, listeners or requests without onCleanup accumulate one more side effect per visit. With onCleanup registered, the count stays at one per live effect and drops when the component is destroyed. active navigations to the view and away no onCleanup: side effects pile up onCleanup: one per live effect A dev-only counter incremented in effect bodies and decremented in onCleanup makes this visible.

Edge Cases and Gotchas

Effects are not for derived state

Using effect() to copy one signal into another creates a chain of cached values and extra work. Prefer computed() for derived values; it is lazy, has no side effects and holds only its latest result.

untracked reads

Signals read inside untracked() do not become dependencies, so changes to them do not re-run the effect. That is useful, but it also means a stale value may be captured in a closure that lives as long as the effect.

Resource-style APIs

Newer Angular APIs for async data (such as resource helpers) manage request cancellation and state for you. Prefer them over hand-written effects that fetch, because they integrate with the owning injector’s lifetime.

Zoneless and change detection

With or without Zone.js, effects follow the same ownership rules. Removing Zone.js reduces some overhead (see Zone.js change detection memory overhead) but does not clean up effects created in the wrong scope.

Frequently Asked Questions

Are Angular effects destroyed automatically?

Yes, when they are created in an injection context whose DestroyRef is destroyed — typically a component or directive. Effects created in root services or with a root injector live for the whole application unless you call destroy() on their EffectRef.

What is onCleanup in an Angular effect?

A function passed to the effect callback that registers teardown logic. It runs before the effect re-executes and when the effect is destroyed, so side effects from the previous run — timers, listeners, requests — are undone.

Do signals leak memory?

A signal holds its current value, and a computed holds its latest result while in use. They leak only in the sense that long-lived signals keep large values alive; scope large state to components or reset it when it is no longer needed.

Is toSignal safe to use in components?

Yes. In a component’s injection context it subscribes immediately and unsubscribes when the component is destroyed. Only manualCleanup: true or creating it with a long-lived injector changes that, and then you are responsible for the subscription’s lifetime.

Should I replace all RxJS subscriptions with signals for memory reasons?

Signals simplify ownership for UI state, but subscriptions managed with takeUntilDestroyed or the async pipe are equally safe. Memory safety comes from tying every subscription, effect and cache to the right lifetime, whichever primitive you use.