DEV Community

Cover image for 10 Angular Performance Hacks to Supercharge Your Web Apps in 2025
Phinter Atieno for Syncfusion, Inc.

Posted on • Originally published at syncfusion.com on

10 Angular Performance Hacks to Supercharge Your Web Apps in 2025

TL;DR: Angular apps often suffer sluggish performance due to inefficient change detection and bloated bundles. This guide explores 10 cutting-edge Angular performance optimization techniques, including signals, zoneless architecture, and lazy loading, to help developers build faster, more responsive applications.

Why Angular performance matters in 2025?

Angular apps often suffer from large bundle sizes and inefficient lazy loading, leading to sluggish first paint and poor user experience.

Angular introduces revolutionary performance improvements that fundamentally change how we build fast applications. With zoneless change detection now stable, enhanced signal-based reactivity, and advanced tree-shaking capabilities, Angular has never been more performant.

Whether you’re migrating from previous versions of Angular or starting fresh, these 10 cutting-edge techniques leverage Angular’s latest features to create blazingly fast applications that excel in Core Web Vitals and provide exceptional user experiences.

1. Go zoneless with change detection

Zoneless change detection is Angular’s most significant performance breakthrough, eliminating Zone.js and providing fine-grained control over when change detection runs.

Why it matters

Zone.js adds significant overhead in payload size (~30KB), startup time, and runtime performance. It triggers change detection unnecessarily since it cannot determine if the application state actually changed. Zoneless change detection runs only when Angular is explicitly notified of changes.

How to apply Zoneless change detection

Step 1: Enable zoneless change detection in your main.ts file.

// main.ts - Angular standalone bootstrap
import { bootstrapApplication } from "@angular/platform-browser";
import { provideZonelessChangeDetection } from "@angular/core";

bootstrapApplication(AppComponent, {
    providers: [
        provideZonelessChangeDetection(),
        // other providers
    ],
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Remove Zone.js from your build.

// angular.json - Remove zone.js from polyfills
{
    "build": {
        "options": {
            "polyfills": [
                // Remove "zone.js" from here
            ]
        }
    },
    "test": {
        "options": {
            "polyfills": [
                // Remove "zone.js/testing" from here
            ]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Uninstall Zone.js dependency by running this command:

npm uninstall zone.js
Enter fullscreen mode Exit fullscreen mode

Step 4: Update components for zoneless compatibility:

import {
    Component,
    ChangeDetectionStrategy,
    inject,
    signal,
} from "@angular/core";

@Component({
    selector: "app-user-profile",
    changeDetection: ChangeDetectionStrategy.OnPush, // Recommended for zoneless
    template: `
        <div class="profile">
            <h2>{{ userData().name }}</h2>
            <p>{{ userData().email }}</p>
            <button (click)="updateProfile()">Update</button>
        </div>
    `,
})
export class UserProfileComponent {
    userData = signal({ name: "John Doe", email: "john@example.com" });

    updateProfile() {
        // Signal updates automatically notify Angular of changes
        this.userData.update((user) => ({
            ...user,
            name: "Updated Name",
        }));
    }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Components must use OnPush-compatible patterns
  • Remove NgZone.onMicrotaskEmpty, NgZone.onUnstable, and NgZone.isStable usage
  • Use afterNextRender or afterEveryRender instead of Zone-based timing
  • Third-party libraries may need compatibility checks

Performance Impact

Zoneless change detection delivers significant performance improvements:

  • Approximately 30KB smaller bundle size
  • 40–60% faster startup time
  • 50–70% reduction in change detection overhead
  • Improved Core Web Vitals scores

2. Prevent zone pollution with strategic NgZone usage

Zone pollution occurs when third-party libraries or inefficient code trigger unnecessary change detection cycles. Understanding zone management helps optimize mixed environments and library integrations even in zoneless applications.

Why it matters

Unnecessary change detection calls can degrade performance by 30-50%. Angular DevTools often shows consecutive change detection bars caused by setTimeout, setInterval, requestAnimationFrame, or event handlers from third-party libraries.

How to apply

Step 1: Run third-party library code outside the Angular zone:

import {Component, NgZone, OnInit, inject, signal} from "@angular/core";
import * as Plotly from "plotly.js-dist-min";

@Component({
    selector: "app-data-visualization",
    template: `
        <div id="plotly-chart"></div>
        <button (click)="updateChart()">Update Data</button>
    `,
})
export class DataVisualizationComponent implements OnInit {
    private ngZone = inject(NgZone);
    chartData = signal<any[]>([]);

    ngOnInit() {
        // Initialize third-party library outside Angular zone
        this.ngZone.runOutsideAngular(() => {
            this.initializePlotly();
        });
    }

    private async initializePlotly() {
        const plotly = await Plotly.newPlot("plotly-chart", this.chartData());

        // Event handlers run outside Angular zone
        plotly.on("plotly_click", (event: any) => {
            // Re-enter Angular zone when updating app state
            this.ngZone.run(() => {
                this.chartData.update((data) => [...data, event]);
            });
        });
    }

    updateChart() {
        // Animations and timers outside Angular zone
        this.ngZone.runOutsideAngular(() => {
            setInterval(() => {
                this.updateVisualization();
            }, 1000);
        });
    }

    private updateVisualization() {
        // Chart update logic
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Handle polling and timers efficiently:

@Component({
    selector: "app-real-time-data",
    template: `
        <div>Last Update: {{ lastUpdate() }}</div>
        <div>Data: {{ liveData() | json }}</div>
    `,
})
export class RealTimeDataComponent implements OnInit, OnDestroy {
    private ngZone = inject(NgZone);
    private intervalId?: number;

    lastUpdate = signal(new Date());
    liveData = signal<any>({});

    ngOnInit() {
        this.ngZone.runOutsideAngular(() => {
            this.intervalId = setInterval(() => {
                this.fetchData().then((data) => {
                    // Re-enter zone only when updating state
                    this.ngZone.run(() => {
                        this.liveData.set(data);
                        this.lastUpdate.set(new Date());
                    });
                });
            }, 5000);
        });
    }

    ngOnDestroy() {
        if (this.intervalId) {
            clearInterval(this.intervalId);
        }
    }

    private async fetchData() {
        return fetch("/api/live-data").then((r) => r.json());
    }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • NgZone.run and NgZone.runOutsideAngular remain compatible with zoneless applications
  • Don’t remove these calls when migrating to zoneless – they provide performance benefits
  • Use Angular DevTools profiler to identify zone pollution patterns

Performance impact

Significant improvements:

  • 25-40% reduction in unnecessary change detection cycles
  • Smoother animations and interactions
  • Better battery life on mobile devices

3. Use Angular Signals for reactive state

Signals are Angular’s new reactive primitive. They provide fine-grained reactivity and automatic dependency tracking, preparing your app for zoneless change detection.

Why it matters

Signals eliminate the need for manual subscription management and provide more predictable updates compared to traditional observables in templates.

How to apply

Step 1: Replace observables with signals:

import { Component, signal, computed } from "@angular/core";

@Component({
    selector: "app-counter",
    template: `
        <div>
            <p>Count: {{ count() }}</p>
            <p>Double: {{ doubleCount() }}</p>
            <button (click)="increment()">+</button>
        </div>
    `,
})
export class CounterComponent {
    count = signal(0);
    doubleCount = computed(() => this.count() * 2);

    increment() {
        this.count.update((value) => value + 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Use signals with the OnPush strategy:

@Component({
    selector: "app-data-display",
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
        @for (item of items()) {
            <div>{{ item.name }}</div>
        }
    `,
})
export class DataDisplayComponent {
    items = signal<Item[]>([]);

    addItem(item: Item) {
        this.items.update((items) => [...items, item]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Migration from observables should be gradual
  • Not all Angular APIs support signals yet

Performance impact: Reduces unnecessary re-renders and prepares for zoneless Angular.

4. Master advanced lazy loading with modern syntax

What it is

Angular’s enhanced lazy loading supports more granular code splitting, intelligent preloading strategies, and seamless integration with standalone components and signals.

Why it matters

Modern applications can exceed 10MB in bundle size. Strategic lazy loading can reduce initial load by 70-85% while intelligent preloading ensures instant navigation when users need it.

How to apply

Step 1: Master standalone component lazy loading:

// app.routes.ts
import {Routes} from "@angular/router";

export const routes: Routes = [
    {
        path: "",
        redirectTo: "/dashboard",
        pathMatch: "full",
    },
    {
        path: "dashboard",
        loadComponent: () =>
            import("./features/dashboard/dashboard.component").then(
                (m) => m.DashboardComponent
            ),
        data: { preload: true }, // Mark for preloading
    },
    {
        path: "admin",
        loadChildren: () =>
            import("./features/admin/admin.routes").then((m) => m.adminRoutes),
        canMatch: [() => inject(AuthService).isAdmin()], // Guard integration
        data: { preload: false },
    },
    {
        path: "reports",
        loadComponent: () =>
            import("./features/reports/reports.component").then(
                (m) => m.ReportsComponent
            ),
        providers: [
            // Route-level providers
            importProvidersFrom(ChartsModule),
            { provide: CHART_CONFIG, useValue: chartConfig },
        ],
    },
];
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement intelligent preloading strategies:

// smart-preloading.strategy.ts
import { PreloadingStrategy, Route } from "@angular/router";
import { Injectable } from "@angular/core";
import { Observable, of, timer } from "rxjs";
import { switchMap } from "rxjs/operators";

@Injectable()
export class SmartPreloadingStrategy implements PreloadingStrategy {
    preload(route: Route, load: Function): Observable<any> {
        return this.shouldPreload(route).pipe(
            switchMap((shouldPreload) => (shouldPreload ? load() : of(null)))
        );
    }

    private shouldPreload(route: Route): Observable<boolean> {
        // Don't preload if user has slow connection
        if (!this.isFastConnection()) {
            return of(false);
        }

        // Preload based on route data
        if (route.data?.["preload"] === true) {
            return of(true);
        }

        // Delay preloading for less critical routes
        if (route.data?.["preload"] === "delayed") {
            return timer(3000).pipe(switchMap(() => of(true)));
        }

        // Default: don't preload
        return of(false);
    }

    private isFastConnection(): boolean {
        // Check if Network Information API is available
        const connection =
            (navigator as any).connection ||
            (navigator as any).mozConnection ||
            (navigator as any).webkitConnection;

        if (!connection) {
            return true; // Assume fast connection if API not available
        }

        // Consider connection fast if 4G or better, or good download speed
        return (
            connection.effectiveType === "4g" ||
            connection.downlink >= 2 ||
            connection.effectiveType === "5g"
        );
    }
}

// app.config.ts
import { ApplicationConfig } from "@angular/core";
import { provideRouter, withPreloading } from "@angular/router";

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes, withPreloading(SmartPreloadingStrategy)),
        SmartPreloadingStrategy,
    ],
};
Enter fullscreen mode Exit fullscreen mode

Step 3: Optimize bundle splitting with strategic imports:

// feature.component.ts - Conditional feature loading
import { Component, signal } from "@angular/core";

@Component({
    selector: "app-analytics-dashboard",
    template: `
        <div class="dashboard">
            <h2>Analytics Dashboard</h2>

            @if (showAdvancedCharts()) {
                <app-advanced-charts [data]="chartData()"></app-advanced-charts>
            } @else {
                <button (click)="loadAdvancedCharts()">Load Advanced Charts</button>
            } @defer (on interaction(exportBtn)) {
                <app-export-tools></app-export-tools>
            } @placeholder {
                <button #exportBtn>Load Export Tools</button>
            }
        </div>
    `,
})
export class AnalyticsDashboardComponent {
    showAdvancedCharts = signal(false);
    chartData = signal<ChartData[]>([
        { id: 1, name: "Sales", value: 1000 },
        { id: 2, name: "Revenue", value: 5000 },
    ]);

    async loadAdvancedCharts() {
        // Dynamic import for heavy chart library
        const { AdvancedChartsModule } = await import(
            "./charts/advanced-charts.module"
        );

        // Set flag to show the component
        this.showAdvancedCharts.set(true);
    }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Avoid circular dependencies in lazy-loaded modules
  • Test preloading strategies on various network conditions
  • Monitor actual vs expected bundle sizes with tools like webpack-bundle-analyzer
  • Consider user behavior patterns when implementing preloading

Performance Impact

Dramatic improvements:

  • 70-85% reduction in initial bundle size
  • 40-60% faster Time to Interactive (TTI)
  • Intelligent preloading reduces perceived navigation time by 80%
  • Better Core Web Vitals scores across all metrics

5. Leverage deferrable views for smart rendering

What it is

Deferrable views provide sophisticated conditional rendering with improved triggers, better error handling, and seamless integration with signals and zoneless change detection.

Why it matters

Deferrable views can improve Initial Bundle Size by 30-50% and Largest Contentful Paint (LCP) by 25-40% by strategically delaying non-critical content rendering.

How to apply

Step 1: Master advanced deferrable view patterns:

// feature.component.ts - Conditional feature loading
import { Component, signal } from "@angular/core";

@Component({
    selector: "app-analytics-dashboard",
    template: `
        <div class="dashboard">
            <h2>Analytics Dashboard</h2>

            @if (showAdvancedCharts()) {
                <app-advanced-charts [data]="chartData()"></app-advanced-charts>
            } @else {
                <button (click)="loadAdvancedCharts()">Load Advanced Charts</button>
            } @defer (on interaction(exportBtn)) {
                <app-export-tools></app-export-tools>
            } @placeholder {
                <button #exportBtn>Load Export Tools</button>
            }
        </div>
    `,
})
export class AnalyticsDashboardComponent {
    showAdvancedCharts = signal(false);
    chartData = signal<ChartData[]>([
        { id: 1, name: "Sales", value: 1000 },
        { id: 2, name: "Revenue", value: 5000 },
    ]);

    async loadAdvancedCharts() {
        // Dynamic import for heavy chart library
        const { AdvancedChartsModule } = await import(
            "./charts/advanced-charts.module"
        );

        // Set flag to show the component
        this.showAdvancedCharts.set(true);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement progressive enhancement with defer blocks:

@Component({
    selector: "app-product-catalog",
    template: `
        <div class="product-catalog">
            <!-- Essential products load immediately -->
            @for (product of essentialProducts(); track product.id) {
                <app-product-card [product]="product"></app-product-card>
            }

            <!-- Advanced filtering deferred until needed -->
            @defer (when showAdvancedFilters()) {
                <app-advanced-filters (filtersChanged)="applyFilters($event)"> 
                </app-advanced-filters>
            } @placeholder {
                <button (click)="showAdvancedFilters.set(true)">
                    Show Advanced Filters
                </button>
            }

            <!-- Load more on viewport -->
            @defer (on viewport(loadMoreTrigger)) { 
                @for (product of additionalProducts(); track product.id) {
                    <app-product-card [product]="product"></app-product-card>
                } 
            } 
            @placeholder {
                <button #loadMoreTrigger>Load More Products</button>
            }

            <!-- Premium banner -->
            @defer (on timer(3s)) {
                <app-premium-banner></app-premium-banner>
            }
        </div>
    `,
})
export class ProductCatalogComponent {
    essentialProducts = signal<Product[]>([
        { id: 1, name: "Laptop", price: 999 },
        { id: 2, name: "Phone", price: 599 },
    ]);

    additionalProducts = signal<Product[]>([
        { id: 3, name: "Tablet", price: 399 },
        { id: 4, name: "Watch", price: 299 },
    ]);

    showAdvancedFilters = signal(false);

    applyFilters(filters: FilterOptions) {
        // Apply filtering logic
        console.log("Applying filters:", filters);
    }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Defer blocks must contain standalone components
  • Avoid referencing deferred components in parent component logic
  • Test defer conditions thoroughly across different user scenarios
  • Monitor the Core Web Vitals impact of defer strategies

Performance Impact

Substantial benefits:

  • 30-50% reduction in initial bundle size
  • 25-40% improvement in Largest Contentful Paint (LCP)
  • 60-80% reduction in Time to Interactive for complex pages
  • Enhanced perceived performance through smart loading patterns

6. Optimize images with NgOptimizedImage

What it is

Angular’s NgOptimizedImage directive provides automatic image optimization, responsive loading, and Core Web Vitals improvements.

Why it matters

Images often represent the largest assets in web applications and significantly impact Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) metrics.

How to apply

Step 1: Import and use NgOptimizedImage:

import { NgOptimizedImage } from "@angular/common";

@Component({
    imports: [NgOptimizedImage],
    template: `
        <img
            ngSrc="hero-banner.jpg"
            width="1200"
            height="600"
            priority
            alt="Hero banner"
        />
    `,
})
export class HeroComponent {}
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure responsive images:

@Component({
    template: `
        <img
            ngSrc="product-image.jpg"
            width="400"
            height="300"
            sizes="(max-width: 768px) 100vw, 50vw"
            placeholder
            alt="Product image"
        />
    `,
})
export class ProductComponent {}
Enter fullscreen mode Exit fullscreen mode

Step 3: Set up image loader for CDN:

// app.config.ts
import { IMAGE_LOADER, ImageLoaderConfig } from "@angular/common";

export const appConfig: ApplicationConfig = {
    providers: [
        {
            provide: IMAGE_LOADER,
            useValue: (config: ImageLoaderConfig) => {
                return `https://your-cdn.com/images/${config.src}?w=${config.width}`;
            },
        },
    ],
};
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Always include width and height to prevent layout shifts
  • Use the priority attribute for above-the-fold images
  • Keep placeholder images under 4KB

Performance Impact: Can improve LCP by 20-40% and eliminate layout shifts.

7. Implement advanced SSR with PendingTasks API

What it is

Angular’s enhanced SSR leverages the PendingTasks API for precise control over application serialization, enabling better hydration strategies and optimal Core Web Vitals.

Why it matters

SSR with proper task management can improve First Contentful Paint (FCP) by 40-60% and Largest Contentful Paint (LCP) by 30-50%, while preventing hydration mismatches that cause layout shifts.

How to apply

Step 1: Configure advanced SSR with hydration optimization:

// main.server.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { provideServerRendering } from "@angular/platform-server";
import {
    provideClientHydration,
    withEventReplay,
} from "@angular/platform-browser";
import { provideZonelessChangeDetection } from "@angular/core";
import { provideRouter } from "@angular/router";
import { AppComponent } from "./app.component";
import { routes } from "./app.routes";

export const appConfig = {
    providers: [
        provideZonelessChangeDetection(),
        provideRouter(routes),
        provideClientHydration(withEventReplay()), // Replay user events during hydration
        provideServerRendering(),
        // Additional providers for your app
    ],
};

export default () => bootstrapApplication(AppComponent, appConfig);
Enter fullscreen mode Exit fullscreen mode

Step 2: Master PendingTasks API for precise SSR control:

// data-loading.service.ts
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { PendingTasks } from "@angular/core";
import { pendingUntilEvent } from "@angular/core/rxjs-interop";
import { catchError, of, finalize } from "rxjs";
import { Observable } from "rxjs";

@Injectable({ providedIn: "root" })
export class DataLoadingService {
    private http = inject(HttpClient);
    private pendingTasks = inject(PendingTasks);

    // Return observables - let components manage subscriptions and signals
    getCriticalData(): Observable<any[]> {
        const taskCleanup = this.pendingTasks.add();

        return this.http.get<any[]>("/api/critical-data").pipe(
            catchError(() => of([])), // Fallback data on error
            pendingUntilEvent(), // Keeps app "unstable" until complete
            finalize(() => taskCleanup()) // Clean up task when observable completes
        );
    }

    // Return observable for secondary data
    getSecondaryData(): Observable<any[]> {
        return this.http.get<any[]>("/api/secondary-data").pipe(
            catchError(() => of([])), // Fallback data on error
            pendingUntilEvent() // Keeps app "unstable" until complete
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Implement hydration-aware components:

import {
    Component,
    OnInit,
    inject,
    signal,
    PLATFORM_ID,
    DestroyRef,
} from "@angular/core";
import { isPlatformBrowser } from "@angular/common";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { DataLoadingService } from "./data-loading.service";

@Component({
    selector: "app-ssr-optimized",
    template: `
        <div class="ssr-container">
            <!-- Critical content rendered server-side -->
            <h1>{{ pageTitle() }}</h1>
            @for (item of criticalData(); track item.id) {
                <div class="critical-item">{{ item.title }}</div>
            }

            <!-- Non-critical content deferred until hydration -->
            @defer (on idle) {
                <h2>Additional Information</h2>
                @for (item of secondaryData(); track item.id) {
                    <div class="secondary-item">{{ item.description }}</div>
                } 
            } 
            @placeholder {
                <div>Loading additional content...</div>
            }
            <!-- Interactive features loaded after hydration -->
            @if (isHydrated()) {
                <app-user-interactions></app-user-interactions>
            }
        </div>
    `,
})
export class SsrOptimizedComponent implements OnInit {
    private dataService = inject(DataLoadingService);
    private platformId = inject(PLATFORM_ID);
    private destroyRef = inject(DestroyRef);

    pageTitle = signal("SSR Optimized Page");
    criticalData = signal<any[]>([]);
    secondaryData = signal<any[]>([]);
    isHydrated = signal(false);

    ngOnInit() {
        // Always load critical data (needed for SSR)
        this.dataService
            .getCriticalData()
            .pipe(takeUntilDestroyed(this.destroyRef))
            .subscribe((data) => this.criticalData.set(data));

        // Only load secondary data and mark as hydrated in browser
        if (isPlatformBrowser(this.platformId)) {
            this.isHydrated.set(true);

            this.dataService
                .getSecondaryData()
                .pipe(takeUntilDestroyed(this.destroyRef))
                .subscribe((data) => this.secondaryData.set(data));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Use PendingTasks instead of Zone-based timing APIs
  • Ensure server and client render the same initial content
  • Test hydration behavior on slow devices and connections
  • Monitor hydration performance in production

Performance Impact

Outstanding improvements:

  • 40-60% improvement in First Contentful Paint (FCP)
  • 30-50% improvement in Largest Contentful Paint (LCP)
  • Elimination of hydration-related Cumulative Layout Shift
  • Better SEO rankings through consistent server rendering

8. Strategic bundle optimization with Tree-Shaking

What it is

Angular’s enhanced tree-shaking capabilities, strategic code organization, and modern build optimizations can dramatically reduce bundle sizes through better dead code elimination.

Why it matters

Every 100KB of JavaScript adds 1-2 seconds to mobile device startup time. Modern bundle optimization can reduce final bundle sizes by 40-70% while maintaining functionality.

How to apply

Step 1: Optimize imports for maximum tree-shaking:

// ❌ Poor tree-shaking - imports entire libraries
import * as _ from "lodash";
import * as moment from "moment";

// ✅ Excellent tree-shaking - specific imports only
import { debounceTime, distinctUntilChanged } from "rxjs/operators";
import { isEqual, cloneDeep } from "lodash-es"; // Use ES modules version
import { format, parseISO } from "date-fns"; // Lightweight alternative

@Injectable()
export class OptimizedDataService {
    private deepClone<T>(obj: T): T {
        return cloneDeep(obj); // Tree-shakable lodash function
    }

    formatDate(date: Date): string {
        return format(date, "yyyy-MM-dd"); // Tree-shakable date-fns function
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement a strategic enum and constant organization:

// ❌ Large monolithic enums that prevent tree-shaking
export enum AppConstants {
    API_ENDPOINTS: { ... }, // 50 endpoints
    ERROR_MESSAGES: { ... }, // 100 messages
    UI_CONSTANTS: { ... }, // 50 UI values
}

// ✅ Granular, tree-shakable constants
// auth/constants.ts
export const AUTH_ENDPOINTS = {
    LOGIN: '/api/auth/login',
    LOGOUT: '/api/auth/logout',
} as const;

export const AUTH_ERRORS = {
    INVALID_CREDENTIALS: 'Invalid username or password',
    SESSION_EXPIRED: 'Your session has expired'
} as const;

// Only import what you actually use
import { AUTH_ENDPOINTS, AUTH_ERRORS } from './auth/constants';
// AUTH_ERRORS won't be bundled if not imported

@Injectable({ providedIn: 'root' })
export class AuthService {
    private http = inject(HttpClient);

    login(credentials: any) {
        return this.http.post(AUTH_ENDPOINTS.LOGIN, credentials);
    }

    logout() {
        return this.http.post(AUTH_ENDPOINTS.LOGOUT, {});
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Optimize service injection and dependencies:

// ❌ Heavy facade service that pulls in everything
@Injectable()
export class AppFacadeService {
    constructor(
        private userService: UserService,
        private orderService: OrderService,
        private paymentService: PaymentService,
        private analyticsService: AnalyticsService // 10+ more services...
    ) {}
}

// ✅ Focused, lightweight services with minimal dependencies
@Injectable()
export class UserProfileService {
    private http = inject(HttpClient);
    private router = inject(Router);

    getUserProfile(id: string) {
        return this.http.get<UserProfile>(`/api/users/${id}`);
    }
}

@Injectable()
export class OrderManagementService {
    private http = inject(HttpClient);
    private paymentService = inject(PaymentService); // Only when needed

    processOrder(order: Order) {
        // Specific functionality with minimal deps
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Implement advanced code splitting strategies:

// feature-loader.service.ts
import { Injectable, signal } from "@angular/core";

@Injectable({ providedIn: "root" })
export class FeatureLoaderService {
    featureFlags = signal({ analytics: false, premiumCharts: false });

    // Load features based on user tier
    loadChartLibrary(userTier: "basic" | "premium") {
        return userTier === "premium"
            ? import(/* "premium-charts" */ "./charts/d3-charts")
            : import(/* "basic-charts" */ "./charts/canvas-charts");
    }

    // Load polyfills only when needed
    loadPolyfillIfNeeded() {
        return !window.IntersectionObserver
            ? import("intersection-observer")
            : null;
    }

    // Feature flag controlled loading
    loadAnalytics() {
        return this.featureFlags().analytics
            ? import(/* "analytics" */ "./analytics/module")
            : null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Avoid barrel imports (index.ts files) for large modules
  • Monitor bundle analyzer reports regularly
  • Test tree-shaking effectiveness with production builds
  • Be cautious with dynamic imports in server-side rendering

Performance Impact

Exceptional results:

  • 40-70% reduction in final bundle size
  • 50-80% improvement in tree-shaking effectiveness
  • 30-50% faster parsing and evaluation time
  • Significant improvement in mobile performance metrics

9. Use RxJS performance patterns

What it is

RxJS operators like shareReplay, distinctUntilChanged, and proper subscription management can significantly impact performance.

Why it matters

Inefficient observable patterns can cause memory leaks, unnecessary HTTP requests, and excessive change detection cycles.

How to apply

Step 1: Use shareReplay for expensive operations:

@Injectable()
export class DataService {
    private data$ = this.http
        .get<Data[]>("/api/data")
        .pipe(shareReplay({ bufferSize: 1, refCount: true }));

    getData() {
        return this.data$;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement proper subscription management:

@Component({...})
export class MyComponent implements OnInit {
    private destroyRef = inject(DestroyRef);

    ngOnInit() {
        this.dataService.getData()
            .pipe(takeUntilDestroyed(this.destroyRef))
            .subscribe(data => {
                // Handle data
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Use an async pipe to avoid manual subscriptions:

@Component({
    template: `
        @if (user$ | async; as user) {
            <div>
                <p>{{ user.name }}</p>
                <p>{{ user.email }}</p>
            </div>
        }
    `,
})
export class UserComponent {
    user$ = this.userService.getCurrentUser();
}
Enter fullscreen mode Exit fullscreen mode

Gotchas to watch out for

  • Avoid multiple async pipes on the same observable
  • Use distinctUntilChanged()for frequently changing observables
  • Consider using startWith() for initial values

Performance Impact: Prevents memory leaks and reduces unnecessary network requests.

10. Implement virtual scrolling for large lists

What it is

Virtual scrolling renders only visible items in a list instead of creating DOM elements for thousands of items. This technique dramatically improves performance when displaying large datasets.

Why it matters

Rendering 10,000+ DOM elements can freeze browsers and consume excessive memory. Virtual scrolling maintains smooth performance regardless of data size by recycling DOM elements as users scroll.

How to apply

Step 1: Set up basic virtual scrolling:

npm install @angular/cdk
Enter fullscreen mode Exit fullscreen mode
import { ScrollingModule } from "@angular/cdk/scrolling";
import { Component, signal } from "@angular/core";

@Component({
    imports: [ScrollingModule],
    template: `
        <h3>Virtual List ({{ items().length }} items)</h3>

        <cdk-virtual-scroll-viewport itemSize="50">
            @for (item of items(); track item.id) {
                <div>{{ item.name }}</div>
            }
        </cdk-virtual-scroll-viewport>
    `,
})
export class VirtualListComponent {
    items = signal(
        Array.from({ length: 100000 }, (_, i) => ({
            id: i,
            name: `Item ${i}`,
        }))
    );
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Optimize with buffer zones for smooth scrolling:

@Component({
    template: `
        <cdk-virtual-scroll-viewport
            itemSize="60"
            [maxBufferPx]="800"
            [minBufferPx]="200"
        >
            @for (item of items(); track item.id) {
                <div class="item">
                    <h4>{{ item.title }}</h4>
                    <p>{{ item.description }}</p>
                </div>
            }
        </cdk-virtual-scroll-viewport>
    `,
})
export class OptimizedVirtualListComponent {
    items = signal<Item[]>([
        /* your large dataset */
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Key configuration options

  • itemSize: Fixed height of each item (required)
  • maxBufferPx: Extra pixels to render above/below viewport (default: 200px)
  • minBufferPx: Minimum buffer to maintain during scrolling (default: 100px)

Gotchas to watch out for

  • Fixed heights work best: Dynamic sizing can cause performance issues
  • Buffer zones matter: Increase maxBufferPx for smoother scrolling on fast devices
  • Mobile testing: Test scrolling performance on actual devices

Performance impact

Outstanding results:

  • Handles 100,000+ items smoothly
  • Constant memory usage regardless of list size
  • 90%+ reduction in initial render time

Performance checklist for Angular (2025)

Core foundations

  • Enable zoneless change detection and remove Zone.js
  • Prevent zone pollution with strategic NgZone usage
  • Use signals with OnPush strategy for all components
  • Implement strategic lazy loading with intelligent preloading

Bundle and rendering

  • Optimize imports and avoid barrel imports
  • Use @defer blocks and NgOptimizedImage
  • Implement virtual scrolling for large datasets

Advanced patterns

  • Configure SSR with PendingTasks API
  • Use modern RxJS patterns and subscription management

Measuring success

Track these key metrics to validate your optimizations:

  • Time to Interactive (TTI): Should improve by 50-70%
  • Largest Contentful Paint (LCP): Target sub-2.5 seconds
  • Cumulative Layout Shift (CLS): Aim for less than 0.1
  • First Input Delay (FID): Should be under 100ms

Conclusion: Mastering Angular performance

In 2025, Angular performance optimization has evolved with powerful new tools like signals and zoneless architecture, giving developers more control and speed than ever. These ten techniques represent a significant leap forward from traditional optimization approaches, offering you the tools to build applications that feel truly exceptional.

Start with what matters most. Implementing zoneless change detection can improve your startup time by 60%. When you combine it with signals, you’ll notice how much smoother your application feels. Add strategic lazy loading, and you’ll see bundle sizes drop by 80%. Each technique works beautifully with the others.

You’re building the future of web applications. Your users will experience sub-second load times, instant interactions, and seamless navigation that feels as smooth as native applications. These aren’t just technical improvements but meaningful enhancements to user experience.

The path forward is clear. Angular has provided you with proven tools and techniques that deliver measurable results. Whether you implement them one by one or tackle several together, each step brings you closer to exceptional performance.

Consider starting with the techniques that align best with your current project needs. Your users will appreciate the difference, and you’ll gain confidence as you see the improvements in action.

The opportunity to create something remarkable is right in front of you.

If you have any questions, contact us through our support forum, support portal, or feedback portal. We are always happy to assist you!

Related Blogs

This article was originally published at Syncfusion.com.

Top comments (0)