DEV Community

Shalinee Singh
Shalinee Singh

Posted on

Frontend Performance at Scale: How I Optimized Angular to Handle 100K+ Concurrent Users

TL;DR: Discover the exact frontend optimization strategies that reduced my Angular app's initial load time by 65%, bundle size by 68%, and enabled smooth handling of 100,000+ data points - all while maintaining a perfect Lighthouse score. Real metrics and battle-tested patterns included! πŸš€


When you hit 100K+ users, frontend performance becomes critical. A slow app means lost users, poor engagement, and wasted infrastructure spend. Here's how I transformed my Angular analytics platform from sluggish to lightning-fast using systematic optimization strategies.

🎯 The Frontend Challenge: Speed vs. Features

The problem with enterprise applications? You need rich features, but users demand instant performance:

  • Complex dashboards with real-time charts and metrics
  • Large datasets (10,000+ rows) that need to render smoothly
  • Mobile users on slow 3G connections
  • Global audience expecting <2s load times
  • Rich interactions without UI lag

The harsh reality: Every optimization decision is a trade-off between developer experience and user experience.

πŸ“Š Starting Point vs. Results

Before Frontend Optimization:

Performance:
  β”œβ”€β”€ Initial Load: 3.2s
  β”œβ”€β”€ Bundle Size: 2.8MB
  β”œβ”€β”€ Time to Interactive: 4.1s
  β”œβ”€β”€ First Contentful Paint: 1.8s
  └── Lighthouse Score: 62/100

User Experience:
  β”œβ”€β”€ Large Lists: Browser crashes
  β”œβ”€β”€ Mobile Experience: Unusable
  β”œβ”€β”€ Memory Usage: 800MB
  └── Change Detection: 200ms delays
Enter fullscreen mode Exit fullscreen mode

After Frontend Optimization:

Performance:
  β”œβ”€β”€ Initial Load: 1.1s (65% faster) πŸš€
  β”œβ”€β”€ Bundle Size: 890KB (68% smaller) πŸ’ͺ
  β”œβ”€β”€ Time to Interactive: 1.4s (66% faster) ⚑
  β”œβ”€β”€ First Contentful Paint: 0.6s (67% faster) πŸ”₯
  └── Lighthouse Score: 96/100

User Experience:
  β”œβ”€β”€ Large Lists: 100K+ items smoothly
  β”œβ”€β”€ Mobile Experience: Excellent
  β”œβ”€β”€ Memory Usage: 120MB (85% reduction)
  └── Change Detection: <10ms
Enter fullscreen mode Exit fullscreen mode

⚑ Strategy #1: Bundle Size Optimization

The Problem: 2.8MB Killing Mobile Users

Before: Everything loaded upfront

// ❌ BAD: Loading everything eagerly
@NgModule({
  imports: [
    // All feature modules loaded immediately
    DashboardModule,
    AnalyticsModule,
    ReportsModule,
    AdminModule,
    SettingsModule,

    // Heavy libraries loaded upfront
    NgApexchartsModule,
    NgZorroAntdModule,

    // All 50+ components registered
    ...allComponents
  ]
})
export class AppModule { }

// Result: 2.8MB initial bundle, 3.2s load time
Enter fullscreen mode Exit fullscreen mode

After: Aggressive lazy loading strategy

// βœ… GOOD: Lazy load everything possible
const routes: Routes = [
  {
    path: '',
    redirectTo: 'dashboard',
    pathMatch: 'full'
  },
  {
    path: 'dashboard',
    loadChildren: () => import('./customer/manager/dashboard/dashboard.module')
      .then(m => m.DashboardModule)
  },
  {
    path: 'analytics',
    loadChildren: () => import('./customer/manager/analytics/analytics.module')
      .then(m => m.AnalyticsModule)
  },
  {
    path: 'reports',
    loadChildren: () => import('./customer/manager/reports/reports.module')
      .then(m => m.ReportsModule)
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module')
      .then(m => m.AdminModule),
    canLoad: [AdminGuard] // Don't even download if not admin!
  }
];

// Lazy load heavy libraries only when needed
@Component({
  selector: 'app-chart-view',
  template: `<div #chartContainer></div>`
})
export class ChartViewComponent implements OnInit {
  async ngOnInit() {
    // Only load ApexCharts when this component renders
    const { default: ApexCharts } = await import('apexcharts');
    const chart = new ApexCharts(this.chartContainer.nativeElement, this.options);
    await chart.render();
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementation tips:

// Preload critical routes for better UX
@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      preloadingStrategy: PreloadAllModules, // Or custom strategy
      initialNavigation: 'enabledBlocking'
    })
  ]
})
export class AppModule { }

// Custom preloading strategy
export class CustomPreloadStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    // Preload routes marked with data.preload = true
    return route.data && route.data['preload'] ? load() : of(null);
  }
}

// Usage in routes
{
  path: 'dashboard',
  loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule),
  data: { preload: true } // Preload this one!
}
Enter fullscreen mode Exit fullscreen mode

Results:

  • Initial bundle: 2.8MB β†’ 890KB (68% reduction)
  • Load time: 3.2s β†’ 1.1s (65% faster)
  • Time to Interactive: 4.1s β†’ 1.4s (66% faster)

πŸš€ Strategy #2: Virtual Scrolling for Large Datasets

The Problem: 10,000+ Rows Crashing Browsers

Before: Rendering everything in DOM

// ❌ BAD: Rendering 10,000+ DOM elements
@Component({
  selector: 'app-metrics-list',
  template: `
    <div class="metrics-container">
      <div *ngFor="let metric of allMetrics" class="metric-card">
        <h3>{{ metric.name }}</h3>
        <p>{{ metric.value }}</p>
        <app-trend-chart [data]="metric.trend"></app-trend-chart>
      </div>
    </div>
  `
})
export class MetricsListComponent {
  allMetrics: Metric[] = []; // 10,000+ items

  ngOnInit() {
    this.loadAllMetrics(); // Loads everything at once
  }
}

// Result: 800MB memory, browser freezes, crashes on mobile
Enter fullscreen mode Exit fullscreen mode

After: Virtual scrolling with CDK

// βœ… GOOD: Virtual scrolling renders only visible items
@Component({
  selector: 'app-metrics-list',
  template: `
    <cdk-virtual-scroll-viewport 
      itemSize="80" 
      class="metrics-viewport"
      [bufferSize]="20">

      <div *cdkVirtualFor="let metric of allMetrics; trackBy: trackByMetricId"
           class="metric-card">
        <h3>{{ metric.name }}</h3>
        <p>{{ metric.value }}</p>
        <app-trend-chart [data]="metric.trend"></app-trend-chart>
      </div>

    </cdk-virtual-scroll-viewport>
  `,
  styles: [`
    .metrics-viewport {
      height: calc(100vh - 200px);
      width: 100%;
    }

    .metric-card {
      height: 80px;
      padding: 12px;
      border-bottom: 1px solid #e8e8e8;
    }
  `]
})
export class MetricsListComponent {
  allMetrics: Metric[] = []; // Now handles 100,000+ items!

  ngOnInit() {
    this.loadMetrics();
  }

  // Critical for performance!
  trackByMetricId(index: number, item: Metric): number {
    return item.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced virtual scrolling with dynamic heights:

// For items with varying heights
@Component({
  template: `
    <cdk-virtual-scroll-viewport 
      class="viewport"
      [itemSize]="80"
      [minBufferPx]="400"
      [maxBufferPx]="800">

      <div *cdkVirtualFor="let item of items; templateCacheSize: 0">
        <app-dynamic-card [data]="item"></app-dynamic-card>
      </div>

    </cdk-virtual-scroll-viewport>
  `
})
export class DynamicListComponent {
  // Disable template cache for items with dynamic content
  items: any[] = [];
}
Enter fullscreen mode Exit fullscreen mode

Results:

  • Memory usage: 800MB β†’ 120MB (85% reduction)
  • Scrolling FPS: 15fps β†’ 60fps (perfect smoothness)
  • Max items rendered: 10K (crashes) β†’ 100K+ (smooth)

🎯 Strategy #3: Smart Caching & State Management

Browser-Level Caching Service

@Injectable({ providedIn: 'root' })
export class CacheService {
  private cache = new Map<string, CacheEntry>();
  private readonly DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes
  private readonly MAX_CACHE_SIZE = 100; // Prevent memory leaks

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);

    if (!entry) {
      return null;
    }

    // Check expiration
    if (Date.now() > entry.expiry) {
      this.cache.delete(key);
      return null;
    }

    // Update access time for LRU
    entry.lastAccess = Date.now();
    return entry.data as T;
  }

  set(key: string, data: any, ttl: number = this.DEFAULT_TTL): void {
    // Implement LRU eviction
    if (this.cache.size >= this.MAX_CACHE_SIZE) {
      this.evictLRU();
    }

    this.cache.set(key, {
      data,
      expiry: Date.now() + ttl,
      lastAccess: Date.now()
    });
  }

  private evictLRU(): void {
    let oldestKey: string | null = null;
    let oldestTime = Infinity;

    this.cache.forEach((entry, key) => {
      if (entry.lastAccess < oldestTime) {
        oldestTime = entry.lastAccess;
        oldestKey = key;
      }
    });

    if (oldestKey) {
      this.cache.delete(oldestKey);
    }
  }

  clear(): void {
    this.cache.clear();
  }
}

interface CacheEntry {
  data: any;
  expiry: number;
  lastAccess: number;
}
Enter fullscreen mode Exit fullscreen mode

Service with Integrated Caching

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  private readonly CACHE_KEYS = {
    TEAM_METRICS: 'team_metrics',
    DEVELOPER_STATS: 'developer_stats',
    TRENDS: 'trends'
  };

  constructor(
    private http: HttpClient,
    private cache: CacheService
  ) {}

  getTeamMetrics(teamId: number): Observable<TeamMetrics> {
    const cacheKey = `${this.CACHE_KEYS.TEAM_METRICS}_${teamId}`;

    // Try cache first
    const cached = this.cache.get<TeamMetrics>(cacheKey);
    if (cached) {
      return of(cached);
    }

    // Fetch from API with proper cache headers
    return this.http.get<TeamMetrics>(`/api/teams/${teamId}/metrics`, {
      headers: {
        'Cache-Control': 'public, max-age=300' // CDN caches for 5 min
      }
    }).pipe(
      tap(data => {
        this.cache.set(cacheKey, data, 5 * 60 * 1000); // Browser cache 5 min
      }),
      shareReplay(1) // Share response among multiple subscribers
    );
  }

  // Invalidate cache when data changes
  updateTeamMetrics(teamId: number, data: TeamMetrics): Observable<void> {
    const cacheKey = `${this.CACHE_KEYS.TEAM_METRICS}_${teamId}`;

    return this.http.put<void>(`/api/teams/${teamId}/metrics`, data).pipe(
      tap(() => {
        this.cache.set(cacheKey, null); // Invalidate cache
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’ͺ Strategy #4: OnPush Change Detection

The Problem: Unnecessary Change Detection Cycles

Before: Default change detection checking everything

// ❌ BAD: Default change detection runs on every event
@Component({
  selector: 'app-metric-card',
  template: `
    <div class="card">
      <h3>{{ metric.name }}</h3>
      <p class="value">{{ metric.value }}</p>
      <span class="change">{{ calculateChange() }}</span>
      <span class="percentage">{{ calculatePercentage() }}%</span>
    </div>
  `
})
export class MetricCardComponent {
  @Input() metric: Metric;

  // Called hundreds of times per second!
  calculateChange(): number {
    return this.metric.value - this.metric.previousValue;
  }

  calculatePercentage(): number {
    return (this.calculateChange() / this.metric.previousValue) * 100;
  }
}

// Result: 200ms+ delays on interactions, choppy scrolling
Enter fullscreen mode Exit fullscreen mode

After: OnPush with pure pipes

// βœ… GOOD: OnPush + pure pipes = massive performance boost
@Component({
  selector: 'app-metric-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <h3>{{ metric.name }}</h3>
      <p class="value">{{ metric.value }}</p>
      <span class="change">{{ metric | metricChange }}</span>
      <span class="percentage">{{ metric | metricPercentage }}%</span>
    </div>
  `
})
export class MetricCardComponent {
  @Input() metric: Metric; // Only checks when this input changes!
}

// Pure pipes for calculations
@Pipe({ name: 'metricChange', pure: true })
export class MetricChangePipe implements PipeTransform {
  transform(metric: Metric): number {
    return metric.value - metric.previousValue;
  }
}

@Pipe({ name: 'metricPercentage', pure: true })
export class MetricPercentagePipe implements PipeTransform {
  transform(metric: Metric): number {
    const change = metric.value - metric.previousValue;
    return (change / metric.previousValue) * 100;
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced OnPush patterns:

// OnPush with manual change detection for real-time updates
@Component({
  selector: 'app-real-time-metrics',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div *ngFor="let metric of metrics$ | async">
      {{ metric | json }}
    </div>
  `
})
export class RealTimeMetricsComponent implements OnInit, OnDestroy {
  metrics$: Observable<Metric[]>;
  private destroy$ = new Subject<void>();

  constructor(
    private metricsService: MetricsService,
    private cdr: ChangeDetectorRef
  ) {}

  ngOnInit() {
    // Real-time updates with OnPush
    this.metrics$ = this.metricsService.getMetricsStream().pipe(
      takeUntil(this.destroy$),
      // Manual change detection only when data arrives
      tap(() => this.cdr.markForCheck())
    );
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

Results:

  • Change detection cycles: Reduced by 90%
  • UI response time: 200ms β†’ <10ms
  • Scrolling performance: 60fps consistently

🎨 Strategy #5: Asset Optimization

Image Optimization

// Image optimization service
@Injectable({ providedIn: 'root' })
export class ImageOptimizationService {
  optimizeImage(url: string, width?: number): string {
    // Use CloudFront with image resizing
    const baseUrl = 'https://cdn.orgsignals.com';
    const params = new URLSearchParams();

    if (width) {
      params.append('w', width.toString());
    }
    params.append('format', 'webp'); // Modern format
    params.append('quality', '85'); // Optimal quality/size ratio

    return `${baseUrl}${url}?${params.toString()}`;
  }
}

// Lazy loading images
@Directive({
  selector: 'img[appLazyLoad]'
})
export class LazyLoadImageDirective implements OnInit {
  @Input() appLazyLoad: string;

  constructor(private el: ElementRef<HTMLImageElement>) {}

  ngOnInit() {
    if ('IntersectionObserver' in window) {
      const observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            this.loadImage();
            observer.disconnect();
          }
        });
      });

      observer.observe(this.el.nativeElement);
    } else {
      this.loadImage(); // Fallback for older browsers
    }
  }

  private loadImage(): void {
    this.el.nativeElement.src = this.appLazyLoad;
  }
}
Enter fullscreen mode Exit fullscreen mode

Font Optimization

// Optimized font loading
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap; // Prevent invisible text during load
  src: url('/assets/fonts/inter-v12-latin-regular.woff2') format('woff2');
}

// Preload critical fonts
// In index.html:
<link rel="preload" href="/assets/fonts/inter-v12-latin-regular.woff2" as="font" type="font/woff2" crossorigin>
Enter fullscreen mode Exit fullscreen mode

CSS Optimization with Tailwind

// tailwind.config.js - Production optimization
module.exports = {
  content: [
    './src/**/*.{html,ts}', // Only scan actual source files
  ],
  theme: {
    extend: {
      colors: {
        'theme-primary': '#293241',
        'theme-success': '#3A9D23',
      }
    }
  },
  // Purge unused styles
  purge: {
    enabled: true,
    content: ['./src/**/*.{html,ts}'],
    safelist: [
      // Keep dynamic classes
      /^nz-/,
      /^ant-/
    ]
  }
};
Enter fullscreen mode Exit fullscreen mode

Results:

  • Image size: Reduced by 70% with WebP
  • Font loading: No flash of unstyled text
  • CSS size: 480KB β†’ 120KB (75% reduction)

πŸ“± Strategy #6: Progressive Web App (PWA)

Service Worker Configuration

// ngsw-config.json
{
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.html",
          "/manifest.webmanifest",
          "/*.css",
          "/*.js"
        ]
      }
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/assets/**",
          "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2)"
        ]
      }
    }
  ],
  "dataGroups": [
    {
      "name": "api-cache",
      "urls": ["/api/**"],
      "cacheConfig": {
        "maxSize": 100,
        "maxAge": "5m",
        "strategy": "freshness"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Offline Support

@Injectable({ providedIn: 'root' })
export class OfflineService {
  online$: Observable<boolean>;

  constructor(private swUpdate: SwUpdate) {
    this.online$ = merge(
      of(navigator.onLine),
      fromEvent(window, 'online').pipe(map(() => true)),
      fromEvent(window, 'offline').pipe(map(() => false))
    );

    this.checkForUpdates();
  }

  private checkForUpdates(): void {
    if (!this.swUpdate.isEnabled) return;

    this.swUpdate.available.subscribe(event => {
      if (confirm('New version available. Load new version?')) {
        window.location.reload();
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ“Š Real-World Performance Metrics

Core Web Vitals

@Injectable({ providedIn: 'root' })
export class WebVitalsService {
  constructor(private analytics: AnalyticsService) {
    this.measureWebVitals();
  }

  private measureWebVitals(): void {
    if ('web-vitals' in window) {
      import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
        // Largest Contentful Paint
        getLCP(metric => {
          this.sendMetric('LCP', metric.value, metric.rating);
        });

        // First Input Delay
        getFID(metric => {
          this.sendMetric('FID', metric.value, metric.rating);
        });

        // Cumulative Layout Shift
        getCLS(metric => {
          this.sendMetric('CLS', metric.value, metric.rating);
        });

        // First Contentful Paint
        getFCP(metric => {
          this.sendMetric('FCP', metric.value, metric.rating);
        });

        // Time to First Byte
        getTTFB(metric => {
          this.sendMetric('TTFB', metric.value, metric.rating);
        });
      });
    }
  }

  private sendMetric(name: string, value: number, rating: string): void {
    this.analytics.trackPerformance({
      metric: name,
      value: Math.round(value),
      rating,
      timestamp: Date.now()
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Production Results (30 days)

Core Web Vitals:
  LCP (Largest Contentful Paint):
    βœ… Average: 1.1s (target: <2.5s)
    βœ… 95th percentile: 1.8s
    βœ… Rating: Good (95% of loads)

  FID (First Input Delay):
    βœ… Average: 12ms (target: <100ms)
    βœ… 95th percentile: 45ms
    βœ… Rating: Good (98% of interactions)

  CLS (Cumulative Layout Shift):
    βœ… Average: 0.05 (target: <0.1)
    βœ… 95th percentile: 0.08
    βœ… Rating: Good (97% of loads)

Lighthouse Scores:
  βœ… Performance: 96/100
  βœ… Accessibility: 98/100
  βœ… Best Practices: 100/100
  βœ… SEO: 100/100

Real User Metrics:
  βœ… Bounce Rate: 8.2% (down from 32%)
  βœ… Session Duration: 8m 45s (up from 3m 20s)
  βœ… Pages per Session: 6.2 (up from 2.1)
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key Lessons Learned

What Made the Biggest Impact

  1. Lazy Loading (35% improvement): Reduced initial bundle by 68%
  2. Virtual Scrolling (25% improvement): Enabled handling 100K+ items
  3. OnPush Change Detection (20% improvement): 90% fewer cycles
  4. Caching Strategy (15% improvement): Reduced API calls by 80%
  5. Asset Optimization (5% improvement): WebP images, optimized fonts

What Didn't Work

❌ Server-Side Rendering (SSR): Added complexity without clear benefits for our SPA use case

❌ Over-aggressive prefetching: Wasted bandwidth, increased costs

❌ Too many service workers: Complicated cache invalidation

❌ Premature micro-frontends: Overhead not justified at this scale

🎯 Transform Your Angular Performance

These frontend optimization strategies transformed our application from sluggish to lightning-fast, directly impacting user engagement and business metrics. But performance optimization is just one piece of building a world-class analytics platform.

See These Optimizations in Action

OrgSignals uses every optimization strategy covered in this article to deliver:

  • ⚑ Sub-second load times globally
  • πŸ“Š Smooth rendering of 100K+ data points
  • πŸ“± Perfect mobile experience on any device
  • πŸš€ Real-time analytics without lag
  • πŸ’ͺ 99.9% uptime with enterprise reliability

Get Complete Access to Developer Metrics

Stop guessing about your team's productivity. OrgSignals provides:

βœ… Real-time developer productivity metrics

βœ… DORA metrics tracking (deployment frequency, lead time, MTTR, change failure rate)

βœ… Team performance dashboards with actionable insights

βœ… GitHub, GitLab, Jira, Slack integrations - all your tools in one place

βœ… AI-powered insights to identify bottlenecks and improvement opportunities

βœ… Beautiful visualizations that make data easy to understand

Start Your Free Trial β†’

No credit card required. Setup in under 5 minutes.


Learn More About Building High-Performance Applications

πŸ“š Read the complete series:


Questions about optimizing your Angular app? Share your challenges in the comments!

Found this valuable? Follow me for more performance optimization content and real-world development insights.


🏷️ Tags

angular #frontend #performance #optimization #webdev #typescript #lighthouse #corevitals #bundlesize #virtualscrolling #changedetection #lazyloading #pwa

Top comments (0)