This article details frontend optimization strategies that reduced an Angular application’s initial load time by 65%, bundle size by 68%, and enabled smooth handling of over 100,000 data points, all while maintaining a perfect Lighthouse score. It includes real metrics and proven patterns.
When an application reaches over 100,000 users, frontend performance becomes critical. A slow application can lead to lost users, poor engagement, and inefficient infrastructure use. This document outlines how an Angular analytics platform was transformed from sluggish to highly responsive through systematic optimization strategies.
The Frontend Challenge: Balancing Speed and Features
Enterprise applications often require rich features, but users consistently demand instant performance. Key challenges include:
- Complex dashboards featuring real-time charts and metrics.
- Large datasets, often exceeding 10,000 rows, requiring smooth rendering.
- Mobile users operating on slower network connections, such as 3G.
- A global audience expecting load times under 2 seconds.
- Rich interactions that must occur without any UI lag.
The reality is: Every optimization decision involves a trade-off between developer experience and user experience.
Starting Point vs. Achieved 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
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
Strategy #1: Bundle Size Optimization
The Problem: Excessive Bundle Size Affecting Mobile Users
Before: All resources 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
After: Aggressive lazy loading strategy implemented
// ✅ 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();
}
}
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!
}
Results:
- Initial bundle size was reduced from 2.8MB to 890KB (a 68% reduction).
- Load time improved from 3.2 seconds to 1.1 seconds (65% faster).
- Time to Interactive decreased from 4.1 seconds to 1.4 seconds (66% faster).
Strategy #2: Virtual Scrolling for Large Datasets
The Problem: Handling 10,000+ Rows Crashing Browsers
Before: Rendering all elements in the 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
After: Virtual scrolling implemented 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;
}
}
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[] = [];
}
Results:
- Memory usage decreased from 800MB to 120MB (an 85% reduction).
- Scrolling FPS improved from 15fps to 60fps (achieving perfect smoothness).
- The maximum number of items rendered increased from 10K (causing crashes) to over 100K (with smooth performance).
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;
}
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
})
);
}
}
Strategy #4: OnPush Change Detection
The Problem: Unnecessary Change Detection Cycles
Before: Default change detection mechanism checking all components
// ❌ 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
After: OnPush strategy applied 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;
}
}
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();
}
}
Results:
- Change detection cycles were reduced by 90%.
- UI response time improved from 200ms to less than 10ms.
- Scrolling performance consistently maintained 60fps.
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;
}
}
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>
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-/
]
}
};
Results:
- Image sizes were reduced by 70% using WebP format.
- Font loading was optimized to eliminate any flash of unstyled text.
- CSS size decreased from 480KB to 120KB (a 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"
}
}
]
}
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();
}
});
}
}
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()
});
}
}
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)
Key Lessons Learned
What Made the Biggest Impact
- Lazy Loading (35% improvement): Achieved a 68% reduction in the initial bundle size.
- Virtual Scrolling (25% improvement): Enabled the smooth handling of over 100,000 items.
- OnPush Change Detection (20% improvement): Resulted in 90% fewer change detection cycles.
- Caching Strategy (15% improvement): Reduced API calls by 80%.
- Asset Optimization (5% improvement): Included WebP images and optimized fonts.
What Didn’t Work
❌ Server-Side Rendering (SSR): Introduced complexity without significant benefits for the Single Page Application (SPA) use case.
❌ Over-aggressive prefetching: Led to wasted bandwidth and increased costs.
❌ Too many service workers: Complicated cache invalidation processes.
❌ Premature micro-frontends: The overhead was not justified at the application’s scale.

