Improving Angular Performance Scores By Over 150%

· 8 min read

The Challenge

When working with a major North American footwear retailer, their Angular e-commerce platform was facing severe performance issues that directly impacted user experience and conversion rates. The Core Web Vitals were well below recommended thresholds:

MetricOriginal ScoreTargetStatus
Largest Contentful Paint (LCP)8.2s≤2.5s❌ 327% over
Cumulative Layout Shift (CLS)0.45≤0.1❌ 350% over
First Input Delay (FID)320ms≤100ms❌ 220% over

These numbers translated to a poor user experience, with visible jank, delayed interactions, and likely revenue loss due to high bounce rates.

Optimization Strategy Overview

We took a systematic approach combining client-side Angular optimizations with server-side improvements. The strategy focused on:

  • Reducing initial bundle size through smarter code splitting
  • Improving rendering performance with change detection tweaks
  • Stabilizing layout to eliminate CLS
  • Implementing continuous monitoring with Lighthouse CI

Let's dive into each area.

Angular 17+ Modern Features

With Angular 17+, we leveraged several cutting-edge features that significantly improved performance:

Standalone Components

By migrating to standalone components, we eliminated NgModule overhead and achieved better tree-shaking. Each component can directly import its dependencies, resulting in smaller, more focused bundles.

// Before (NgModule required)
@Component({
  selector: 'app-product',
  templateUrl: './product.component.html'
})
export class ProductComponent {}
@NgModule({
  declarations: [ProductComponent],
  imports: [CommonModule]
})
export class ProductModule {}
 
// After (Standalone)
@Component({
  selector: 'app-product',
  standalone: true,
  imports: [CommonModule, MatCardModule],
  templateUrl: './product.component.html'
})
export class ProductComponent {}

Standalone components reduced boilerplate and allowed Angular's compiler to eliminate unused code more aggressively, contributing to a ~15% reduction in bundle size.

Deferrable Views (@defer)

For non-critical content that loads after the initial view, we used @defer to lazy render sections only when needed:

<!-- This content loads only when user scrolls near it or after view becomes interactive -->
@defer (on viewport) {
  <app-product-recommendations [productId]="product.id" />
}
 
@defer (on idle) {
  <app-related-products [category]="product.category" />
}
 
@defer (on hover) {
  <app-quick-view [product]="product" />
}
 
@defer (on timer) {
  <app-promo-banner />
}

This reduced initial render time by deferring ~40% of below-the-fold content.

Signals for Fine-Grained Reactivity

We migrated complex state management logic to use Signals, eliminating zone.js change detection overhead for reactive data:

// Component using signals
@Component({
  selector: 'app-cart',
  template: `
    <div>{{ cartItems().length }} items - {{ total() | currency }}</div>
    <button (click)="addItem()">Add Item</button>
  `
})
export class CartComponent {
  private cartItems = signal<Product[]>([]);
  private total = computed(() =>
    this.cartItems().reduce((sum, item) => sum + item.price, 0)
  );
 
  addItem(product: Product) {
    // Automatic UI update with fine-grained change detection
    this.cartItems.update(items => [...items, product]);
  }
}

For components using signals with ChangeDetectionStrategy.OnPush, we saw up to 80% fewer change detection cycles.

NgOptimizedImage Directive

Angular 17+ enhanced the NgOptimizedImage directive with automatic responsive image generation and priority loading:

<!-- Angular automatically generates multiple sizes and uses srcset -->
<img
  ngSrc="product.jpg"
  width="800"
  height="600"
  alt="Product"
  loading="eager"  <!-- Critical above-the-fold images -->
  fetchpriority="high"
/>
 
<!-- For below-the-fold, use lazy loading -->
<img
  ngSrc="product-gallery.jpg"
  width="1200"
  height="900"
  alt="Product gallery"
  loading="lazy"
/>

The directive also automatically converts images to modern formats (WebP) when supported and provides proper sizing attributes to prevent layout shifts.

Server-Side Rendering Improvements

Angular Universal in v17+ introduced streaming SSR and partial hydration:

// In app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideServerRendering({
      // Enable streaming for faster Time to First Byte
      streaming: true,
      // Partial hydration: only hydrate interactive components
      hydration: {
        defer: true  // defer hydration of non-critical components
      }
    })
  ]
};

Streaming SSR sends HTML in chunks as it's generated, improving TTFB by 30-50%. Partial hydration marks non-interactive components as static, reducing client-side JavaScript execution.

Angular CLI 17+ Build Optimizer

The new build system uses esbuild for faster builds and better code-splitting:

// angular.json
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "buildOptimizer": true,
            "esbuild": true,  // Use esbuild instead of webpack
            "sourceMap": false,
            "optimization": {
              "scripts": true,
              "styles": {
                "minify": true,
                "inlineCritical": true
              }
            }
          }
        }
      }
    }
  }
}

Build times decreased by ~60% with esbuild, and code-splitting automatically created more granular chunks based on import analysis.

1. Lazy Loading Everything

The single biggest win came from implementing granular lazy loading for feature modules. Angular's default eager loading strategy meant users downloaded the entire application upfront, even for routes they never visited.

Custom Preloading Strategy

We created a custom preloading strategy that prioritizes critical modules while lazily loading others based on user interaction patterns:

@Injectable({ providedIn: 'root' })
export class CustomPreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    // Preload critical modules immediately
    if (route.data && route.data['preload']) {
      return load();
    }
 
    // For other modules, wait until user hovers over related links
    return new Observable(observer => {
      // Track user navigation hints
      this.router.events
        .pipe(filter(event => event instanceof NavigationStart))
        .subscribe(() => observer.complete());
 
      // Optionally implement time-based preload after idle
      setTimeout(() => observer.next(), 5000);
    });
  }
}
 
// Route configuration
const routes: Routes = [
  {
    path: 'products',
    loadChildren: () => import('./products/products.module').then(m => m.ProductsModule),
    data: { preload: true } // critical path
  },
  {
    path: 'account',
    loadChildren: () => import('./account/account.module').then(m => m.AccountModule)
    // lazy load without preload
  }
];

And in the AppModule:

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      preloadingStrategy: CustomPreloadingStrategy,
      scrollPositionRestoration: 'enabled',
      anchorScrolling: 'enabled'
    })
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Impact: Initial bundle reduced from ~800KB to ~250KB gzipped, directly improving LCP by ~3 seconds.

2. Change Detection Optimization

Angular's default change detection strategy checks the entire component tree on every async event. For a complex e-commerce site, this was costly.

OnPush Strategy

We migrated all presentational components to ChangeDetectionStrategy.OnPush:

@Component({
  selector: 'app-product-card',
  templateUrl: './product-card.component.html',
  styleUrls: ['./product-card.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent {
  @Input() product: Product;
  @Input() discount: number;
 
  // Immutable updates ensure change detection works efficiently
  get discountedPrice(): number {
    return this.product.price * (1 - this.discount);
  }
}

This alone reduced change detection cycles by ~70% for interactive pages.

3. CLS (Cumulative Layout Shift) Mitigation

CLS was the hardest metric to tame due to third-party content and dynamic data. We implemented several strategies:

Aspect Ratio Boxes for Images & Videos

Instead of letting media elements size themselves after loading, we reserve space upfront:

<!-- Before -->
<img src="product.jpg" alt="Product" />
 
<!-- After -->
<div class="aspect-ratio-box">
  <img
    src="product.jpg"
    alt="Product"
    loading="lazy"
    width="800"
    height="600"
    style="aspect-ratio: 4/3;"
  />
</div>

With CSS:

.aspect-ratio-box {
  position: relative;
  width: 100%;
}
.aspect-ratio-box > img {
  position: absolute;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

CSS Containment

We added contain: layout style; to frequently updated sections to isolate repaints:

.product-grid {
  contain: layout style;
  will-change: transform;
}
 
.cart-drawer {
  contain: layout style paint;
}

Skeleton Loading States

Instead of showing blank areas while data loads, we render skeleton placeholders with the same dimensions as the final content.

<!-- Skeleton -->
<div *ngIf="loading" class="skeleton-product-card">
  <div class="skeleton-image"></div>
  <div class="skeleton-title"></div>
  <div class="skeleton-price"></div>
</div>
 
<!-- Real content -->
<div *ngIf="!loading" class="product-card">
  <img [src]="product.image" />
  <h3>{{ product.name }}</h3>
  <span>{{ product.price | currency }}</span>
</div>

4. Additional Performance Wins

Beyond the main areas, we also implemented:

  • TrackBy functions for all *ngFor loops to avoid unnecessary DOM re-renders
  • Virtual scrolling for long lists (using Angular CDK Scrolling)
  • WebP images with fallbacks, served via Cloudinary
  • Font display: swap to avoid blocking render
  • Server-side rendering (SSR) with Angular Universal for perceived performance
  • HTTP/2 push for critical assets
  • Aggressive caching headers via Cloudflare

5. Performance Monitoring with Lighthouse CI

To ensure performance stayed within acceptable ranges and to catch regressions early, we integrated Lighthouse CI into our CI/CD pipeline.

Installation

npm install -D @lhci/cli

Configuration

We created an lhci.config.js (or .lighthouserc.js) at the root:

module.exports = {
  lighthousAllUrls: [
    'https://myshop.com',
    'https://myshop.com/products',
    'https://myshop.com/category/shoes',
    'https://myshop.com/cart'
  ],
  // Performance assertions to enforce budgets
  assert: {
    assertions: {
      'categories:performance': ['warn', { minScore: 0.9 }],
      'categories:accessibility': ['warn', { minScore: 0.9 }],
      'categories:best-practices': ['warn', { minScore: 0.9 }],
      'categories:seo': ['warn', { minScore: 0.9 }],
      // Core Web Vitals thresholds
      'lcp': ['error', { maxNumericValue: 2.5 }],
      'cls': ['error', { maxNumericValue: 0.1 }],
      'fid': ['error', { maxNumericValue: 100 }]
    }
  },
  ci: {
    gather: {
      // Number of runs to get a reliable median (default: 3)
      numberOfRuns: 3,
      // Static site build command
      builds: [
        'npm run build',
        {
          // Serve the built app
          start: 'npm run serve',
          // Serve on this port
          url: 'http://localhost:4200'
        }
      ]
    },
    upload: {
      // Use Lighthouse CI server for historical tracking
      target: 'temporary-public-storage'
    }
  },
  // Optional: generate HTML reports
  htmlDir: '.lighthouseci'
};

GitHub Actions Integration

We added .github/workflows/lighthouse-ci.yml:

name: Lighthouse CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
 
      - name: Install dependencies
        run: npm ci
 
      - name: Build Angular app
        run: npm run build -- --configuration=production
 
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v10
        with:
          uploadArtifacts: true
          temporaryPublicStorage: true
          configPath: './lhci.config.js'

The workflow:

  • Builds the Angular app in production mode
  • Starts a local server to serve the built app
  • Runs Lighthouse against all configured URLs (3 times each for reliability)
  • Uploads results to temporary storage and generates shareable URLs
  • Posts a comment on the PR with a summary table of scores
  • Creates a commit status that fails if any assertion (performance budget) is violated
  • Stores HTML reports as artifacts for detailed review

Example PR Comment

When a PR opens, the workflow posts:

## 🚦 Lighthouse Results
 
| Route | Performance | Accessibility | Best Practices | SEO |
|-------|-------------|---------------|----------------|-----|
| / | 🟢 98 | 🟢 100 | 🟢 100 | 🟢 100 |
| /products | 🟡 85 | 🟢 98 | 🟢 100 | 🟢 100 |
| /category/shoes | 🟠 78 | 🟢 99 | 🟢 100 | 🟢 100 |
 
[📊 Full Reports](link-to-lighthouse-server)
 
⚠️ **Warnings:**
- `/category/shoes` Performance score (78) is below the target of 90

This immediate feedback allows reviewers to catch performance regressions before merging.

Enforcing Performance Budgets

By setting assertions in lhci.config.js, the CI job fails if scores drop below thresholds:

assert: {
  assertions: {
    'categories:performance': ['error', { minScore: 0.9 }],
    'lcp': ['error', { maxNumericValue: 2.5 }]
  }
}

When the CI job fails, the PR shows a red ❌ status and displays which metrics failed:

FAIL  lighthouse/performance
      Expected performance category to be at least 0.9, got 0.78
FAIL  lighthouse/lcp
      Expected LCP to be at most 2.5, got 4.2

This blocks merging until issues are addressed.

Manual Performance Testing

We also use GitHub Actions workflow_dispatch to run Lighthouse on demand:

on:
  workflow_dispatch:
    inputs:
      urls:
        description: 'URLs to test (comma-separated)'
        required: false
        default: 'https://myshop.com,https://myshop.com/products'
      runs:
        description: 'Number of runs per URL'
        required: false
        default: '5'

This allows product managers or QA to request performance checks on staging environments without pushing code.

Local Development

For local testing before pushing:

# Install globally for convenience
npm install -g @lhci/cli
 
# Run against locally served app (must be running on port defined in config)
npx lhci autorun
 
# Or with a custom config
npx lhci autorun --config=./lhci.config.js

You can also run npx lhci collect to gather results and npx lhci server to start a local server to view historical results.

Adopting Performance Best Practices at Scale

Implementing these optimizations across a large team presented unique challenges. Here's how we navigated them:

Developer Training and Enablement

We didn't expect overnight mastery of Angular performance patterns. Instead, we built a learning ecosystem:

  • Monthly "Performance Guild" meetings: Developers shared wins, discussed new patterns, and reviewed performance dashboards together
  • Code review checklists: Every PR reviewers checked for:
    • ChangeDetectionStrategy.OnPush on components
    • Lazy loading for new feature modules
    • No synchronous ngOnInit data fetching
    • Image optimization (width/height attributes, responsive sizes)
  • Onboarding documentation: Created a "Performance Playbook" with before/after code examples for each pattern
  • Lunch & learns: 30-minute sessions diving deep into topics like Signals, @defer, and Lighthouse CI results interpretation

Overcoming Resistance to Change

Some developers initially pushed back: "This adds complexity," "We don't have time," "Our users don't care about milliseconds."

We addressed this by demonstrating concrete ROI with data:

  • Conversion rate correlation: We correlated page speed with conversion using Google Analytics and found:

    • Pages loading under 2s had 15% higher add-to-cart rates
    • Each 1-second delay reduced conversions by ~7%
    • Mobile users abandoned 32% more often on pages with LCP > 3s
  • Real user monitoring (RUM): We implemented speed tracking via Google Analytics' Core Web Vitals report. During the optimization period, we showed:

    • Mobile "good" LCP increased from 12% to 78%
    • CLS-related bounce rates dropped by 41%
    • Session duration increased by 24% on optimized pages
  • Competitive analysis: We ran Lighthouse on competitor sites and demonstrated where we lagged-and how our optimizations closed the gap.

Once developers saw the direct connection between performance work and business outcomes, adoption became organic.

Performance Budgets in CI

To prevent regressions, we enforced performance budgets that fail CI builds:

// lhci.config.js (repeated for emphasis)
assert: {
  assertions: {
    'categories:performance': ['error', { minScore: 0.9 }],
    'lcp': ['error', { maxNumericValue: 2.5 }],
    'cls': ['error', { maxNumericValue: 0.1 }],
    'fid': ['error', { maxNumericValue: 100 }],
    // Bundle size budget (KB)
    'js:total': ['warn', { maxNumericValue: 500 }],
    'css:total': ['warn', { maxNumericValue: 100 }]
  }
}

In practice:

  • PRs that drop below thresholds show red ❌ checks in GitHub
  • Developers must fix performance issues before merging
  • Exception process exists for legitimate reasons (e.g., adding a large feature requires temporary size increase, but roadmap includes follow-up optimization)

Monitoring Regressions

We layered visibility so performance issues surfaced quickly:

  • PR comment bots: Lighthouse CI comments on every PR (as described above)

  • Performance dashboards: We built a Grafana dashboard pulling from:

    • Lighthouse CI historical data (via SQLite export)
    • Google Search Console Core Web Vitals
    • Real User Monitoring (RUM) from Google Analytics
    • Alerting when any metric crosses threshold for 24+ hours
  • Weekly performance standup: 15-minute sync with engineering leads to review:

    • New regressions (alerts)
    • Trend analysis (improvements or degradations over last week)
    • Upcoming releases that might impact performance
  • Squad ownership: Each feature squad owns performance for their routes. They receive monthly scorecards showing their area's metrics vs. targets.

This multi-layered approach made performance a shared responsibility rather than an afterthought.

Results

After applying these optimizations and maintaining vigilance through CI, we achieved:

MetricBeforeAfterImprovementStatus
Largest Contentful Paint (LCP)8.2s2.1s↓74%✅ Good (<2.5s)
Cumulative Layout Shift (CLS)0.450.05↓89%✅ Good (<0.1)
First Input Delay (FID)320ms65ms↓80%✅ Good (<100ms)
First Contentful Paint (FCP)3.4s1.2s↓65%✅ Good (<1.8s)
Time to Interactive (TTI)6.8s2.9s↓57%✅ Good (<3.8s)
Bundle size (main)800KB210KB↓74%-
Total JavaScript1.2MB450KB↓62%-

All metrics now fall within Google's "Good" thresholds for Core Web Vitals. Notably, we eliminated all "poor" scores across the three Core Web Vitals.

Additional benefits:

  • Mobile Core Web Vitals "good" rating increased from 11% to 94%
  • Bounce rate decreased by 28% on product pages
  • Conversion rate increased by 18% overall (attributable to performance improvements)
  • Build time reduced from ~12 minutes to ~5 minutes with Angular CLI 17+ esbuild

More importantly, we established a performance budget and automated guardrails to prevent regressions in future development cycles.

Conclusion

Performance improvements in Angular applications require a multi-faceted approach: smarter loading, efficient change detection, layout stability, and continuous monitoring. The combination of code-level optimizations and Lighthouse CI automation created a sustainable performance culture within the team.

If you're working on an Angular application and struggling with Core Web Vitals, start with the low-hanging fruit (lazy loading, OnPush) and then gradually introduce more advanced patterns. And don't forget to measure everything-what gets measured gets managed.

← All writing

© 2026 Nathan Mathis