Angular applications have a reputation for being heavy. In my experience, that reputation comes less from the framework and more from a few recurring patterns: everything loaded up front, default change detection running across a huge component tree, and RxJS subscriptions that nobody cleans up.
These are the techniques that make the biggest practical difference, roughly in the order I apply them when auditing an Angular application.
1. Measure first
Before changing code, collect evidence:
- Angular DevTools (browser extension) — the Profiler tab shows each change-detection cycle, which components were checked and how long they took.
- Chrome Performance panel — reveals long tasks, forced layouts and whether time is spent in scripting or rendering.
- Build stats —
ng build --stats-jsoncombined with a bundle analyser shows exactly what ends up in each bundle. - Lighthouse / Core Web Vitals — for load performance of public-facing pages.
Record a baseline: initial bundle size, time to interactive on a mid-range device, and the slowest user interactions.
2. Lazy load features
The single biggest load-time improvement in most enterprise Angular applications is to stop shipping the entire application on first load.
With standalone components, route-level lazy loading is straightforward:
export const routes: Routes = [
{ path: '', component: DashboardComponent },
{
path: 'reports',
loadChildren: () => import('./reports/reports.routes').then((m) => m.REPORTS_ROUTES),
},
{
path: 'settings',
loadComponent: () => import('./settings/settings.component').then((m) => m.SettingsComponent),
},
];Go further with deferrable views (@defer) for heavy parts of a page that are not needed immediately — charts below the fold, rich editors, map widgets:
@defer (on viewport) {
<app-sales-chart [data]="sales()" />
} @placeholder {
<div class="chart-skeleton"></div>
}Choose a preloading strategy deliberately. PreloadAllModules helps navigation speed but downloads everything eventually; a custom strategy that preloads only likely next routes is often a better balance.
3. Get change detection under control
By default, Angular checks every component in the tree whenever something might have changed — a click, a timer, an HTTP response. In a large tree with complex templates, that adds up.
Use OnPush
ChangeDetectionStrategy.OnPush tells Angular to check a component only when its inputs change by reference, when an event originates inside it, or when an observable bound with the async pipe (or a signal) emits.
@Component({
selector: 'app-order-row',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './order-row.component.html',
})
export class OrderRowComponent {
order = input.required<Order>();
}OnPush works best with immutable data: create a new object or array when data changes instead of mutating it. Mutation is the most common reason OnPush "breaks" an application — the data changed but the reference did not.
Adopt signals
Signals give Angular fine-grained knowledge of what changed. Components that read signals in their templates are updated precisely, and computed() values are memoised automatically. For new code, signal-based inputs and state are the direction the framework is heading, and they combine well with OnPush. Newer Angular versions can also run zoneless, removing Zone.js overhead entirely once the application is ready for it.
Avoid work in templates
Function calls in templates run on every change-detection cycle:
<!-- Runs on every check -->
<span>{{ calculateTotal(order) }}</span>Use a computed() signal, a pure pipe, or precompute the value when data arrives.
Use track in loops
With the built-in control flow, always provide a stable identity:
@for (order of orders(); track order.id) {
<app-order-row [order]="order" />
}Without a meaningful track, Angular may destroy and recreate DOM for items that did not change.
4. Use RxJS carefully
RxJS is powerful, and most Angular performance and memory problems I debug involve it somewhere.
- Clean up subscriptions. Prefer the
asyncpipe ortoSignal(), which handle unsubscription automatically. For manual subscriptions, usetakeUntilDestroyed(). - Pick the right flattening operator.
switchMapfor search (cancel stale requests),concatMapwhen order matters,exhaustMapfor "ignore clicks while submitting". UsingmergeMapeverywhere leads to race conditions and duplicate requests. - Share expensive streams. Several
asyncpipes on the same cold HTTP observable make several HTTP requests. UseshareReplay({ bufferSize: 1, refCount: true })or convert to a signal once. - Debounce user input with
debounceTimeanddistinctUntilChangedbefore hitting the API.
results = toSignal(
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q)),
),
{ initialValue: [] },
);5. Reduce bundle size
- Check budgets in
angular.jsonand make the build fail when they are exceeded, rather than only warning. - Look for heavy dependencies pulled into the main bundle: full lodash, moment.js with all locales, entire icon sets, chart libraries on the landing screen.
- Remove unused Angular Material or third-party modules, and import only the components you use.
- Make sure production builds use the modern application builder (esbuild-based), which is significantly faster and produces smaller output than older webpack configurations.
6. Render large data efficiently
Tables with thousands of rows are common in enterprise applications. Use virtual scrolling (@angular/cdk/scrolling) so only visible rows exist in the DOM, or use server-side pagination. Combine this with OnPush row components and a proper track expression.
7. Consider server-side rendering where it matters
For public, SEO-relevant pages, Angular's SSR with hydration improves first paint and search visibility. For authenticated dashboards used on desktops, client rendering is usually fine, and SSR adds infrastructure without much benefit. Decide per application, not by default.
8. Architect components to stay fast
Performance is partly an architecture concern:
- Keep presentational components simple, input-driven and OnPush.
- Keep data loading in container components or services, not scattered across leaf components.
- Avoid deeply nested component trees where each level adds bindings for data it only passes through.
- Put shared state in services with signals or a well-structured store, not in ad-hoc component properties.
A realistic plan
For an existing application, I usually recommend this sequence:
- Baseline measurements and bundle analysis.
- Route-level lazy loading and removal of obviously heavy dependencies.
- OnPush and
trackon list-heavy and frequently updated components. - RxJS clean-up: subscriptions, flattening operators, shared streams.
- Virtual scrolling or pagination for large data views.
- Gradual adoption of signals — and eventually zoneless — in new and refactored code.
Each step is measurable and can ship independently, so you get improvements without a risky rewrite. If your Angular application has become sluggish and you want a clear plan rather than guesswork, a focused performance audit is a good place to start.