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.
Step-by-Step Fix
- Inventory effects and their owners. Search for
effect(and note where each is created: component, directive, root service, or with an explicitinjector. Verification: you know which effects outlive the views that need them. - 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).
- Destroy effects you create manually. When an effect must be created with an explicit injector or conditionally, keep its
EffectRefand calldestroy()when done. Verification: the effect no longer runs afterdestroy(). - Use
onCleanupfor side effects. Stop timers, remove listeners, abort requests and unsubscribe within theonCleanupcallback. Verification: changing the tracked signal ten times leaves exactly one active timer or listener. - Scope large signal state. Keep large datasets in component-provided stores, or reset root signals to
nullwhen leaving the page. Verification: heap snapshots after navigation no longer contain old datasets. - Check
toSignalusage. AvoidmanualCleanup: trueunless you have a clear teardown; calltoSignalin the component’s injection context. Verification: the underlying observable’s subscription count drops when the component is destroyed.
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.
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.
Related
- Angular RxJS Subscription Memory Leaks — the parent topic
- Dynamic Component and ViewContainerRef Leaks — components created at runtime and their teardown
- Using effectScope for Vue Cleanup — the equivalent ownership problem in Vue
- Framework-Specific Memory Optimization — the section overview