Back to ER Diagram
Frontend Memory Management

Frontend Memory Management

Comprehensive patterns for RxJS subscription cleanup, NgRx store optimization, virtual scrolling, Web Workers, and Chrome memory limit handling to prevent renderer process crashes for 500+ concurrent users.

Angular 21
NgRx Store
RxJS
SignalR
Web Workers

Overview

ReqVise frontend targets 500+ concurrent users with complex workflows including reverse auctions, large PO lists, vendor management, and audit logs. Without proper memory management, Chrome tabs can crash when exceeding the ~2GB memory limit. This documentation covers all patterns required to prevent memory leaks and ensure smooth performance.

Chrome Tab Memory Limit: ~2GB - Prevent renderer process crashes
500+
Concurrent Users
<500MB
Target Memory Usage
<50MB/hr
Max Memory Growth
100 items
Virtual Scroll Threshold

Common Memory Leak Sources

  • Unsubscribed RxJS subscriptions
  • NgRx store accumulating stale entities
  • SignalR subscriptions not cleaned up
  • Chart.js instances not destroyed
  • DOM event listeners not removed
  • Large lists rendered without virtualization

Prevention Strategies

  • takeUntilDestroyed() for all subscriptions
  • Entity pagination in NgRx store
  • Virtual scrolling for lists > 100 items
  • Web Workers for heavy operations
  • Chart cleanup in ngOnDestroy()
  • Memory monitoring in development

RxJS Subscription Management

Every subscribe() call creates a subscription that must be cleaned up. Angular 21 provides takeUntilDestroyed() from @angular/core/rxjs-interop as the preferred pattern.

1

Pattern: takeUntilDestroyed() (Recommended)

Use Angular 21's built-in destroy ref for automatic cleanup. Works in injection context.

import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({ ... })
export class POListComponent {
  private poService = inject(POService);

  // Subscription auto-cleaned when component destroyed
  pos$ = this.poService.getPOs().pipe(
    takeUntilDestroyed()
  );

  // For subscriptions in constructor/field initializer
  constructor() {
    this.poService.refresh$
      .pipe(takeUntilDestroyed())
      .subscribe(() => this.loadPOs());
  }
}
2

Pattern: async Pipe (Template Subscriptions)

Use async pipe for automatic subscription management in templates.

<!-- Component Template -->
<div *ngIf="pos$ | async as poList">
  <app-po-table [data]="poList"></app-po-table>
</div>

<!-- Component Class -->
@Component({ ... })
export class POListComponent {
  // No manual subscribe needed - async pipe handles cleanup
  pos$ = this.store.select(selectAllPOs);
}
3

Pattern: shareReplay with refCount

For shared observables, use shareReplay with refCount: true to auto-cleanup.

@Injectable({ providedIn: 'root' })
export class VendorService {
  // Shared observable with auto-cleanup when no subscribers
  vendors$ = this.http.get<Vendor[]>('/api/vendors').pipe(
    shareReplay({ bufferSize: 1, refCount: true })
  );

  // BAD: Without refCount, subscription never completes
  // vendorsBad$ = this.http.get('/api/vendors').pipe(
  //   shareReplay(1) // Memory leak!
  // );
}

Business Rule: BR-MEM-001

Every subscribe() call MUST have a corresponding cleanup strategy (takeUntilDestroyed(), async pipe, or explicit unsubscribe()).

NgRx Store Optimization

NgRx store can accumulate large amounts of data over time. Implement pagination, cleanup on navigation, and memoized selectors to prevent memory bloat.

Store Cleanup Patterns

Pattern Implementation When to Use
Entity Pagination Max 100 items per page in store PO List, Vendor List, Audit Logs
Feature State Cleanup clearState action on route navigation Lazy-loaded modules (Procurement, Vendor Portal)
Selector Memoization createSelector() with input selectors All derived data (filtered lists, computed values)
Stale Data Eviction Remove entities older than 30 minutes Real-time data (auction bids, notifications)
// Feature state with pagination
export interface POState {
  entities: Record<string, PO>;
  ids: string[];
  currentPage: number;
  pageSize: number;         // Max 100
  totalCount: number;
  loading: boolean;
}

// Cleanup action dispatched on route leave
export const clearPOState = createAction('[PO] Clear State');

// Reducer handling cleanup
on(clearPOState, (state) => ({
  ...initialState,
  // Optionally retain some data like filters
  filters: state.filters
}));

// Route guard for cleanup
@Injectable()
export class POCleanupGuard implements CanDeactivate<POListComponent> {
  constructor(private store: Store) {}

  canDeactivate(): boolean {
    this.store.dispatch(clearPOState());
    return true;
  }
}

Business Rule: BR-MEM-006

Module feature states MUST be cleared when leaving lazy-loaded modules to prevent stale data accumulation.

Virtual Scrolling

Rendering large lists (1000+ items) creates thousands of DOM nodes, consuming memory and degrading performance. Virtual scrolling renders only visible items plus a small buffer.

Views Requiring Virtual Scrolling

View Trigger Condition Implementation
PO List > 100 records cdk-virtual-scroll-viewport
Vendor List > 100 records cdk-virtual-scroll-viewport
Audit Logs Always (infinite scroll) Intersection Observer + pagination
PR Line Items > 50 items Table virtualization
GRN Items > 50 items Table virtualization
Reverse Auction Bids > 100 bids Virtual scroll with live updates
<!-- Virtual Scroll for PO List -->
<cdk-virtual-scroll-viewport
  itemSize="48"
  minBufferPx="400"
  maxBufferPx="800"
  class="po-list-viewport"
>
  <app-po-row
    *cdkVirtualFor="let po of pos$; trackBy: trackByPoId"
    [po]="po"
  ></app-po-row>
</cdk-virtual-scroll-viewport>

// Component class
@Component({ ... })
export class POListComponent {
  // Track by function prevents re-rendering unchanged items
  trackByPoId = (index: number, po: PO) => po.id;
}

Performance Impact

Virtual scrolling reduces DOM nodes from 10,000+ to ~50 for a list of 10,000 items, saving ~95% memory.

Web Workers for Heavy Operations

Heavy computations (Excel parsing, large data transformations) block the main thread, causing UI freezes. Web Workers run in a separate thread, keeping the UI responsive.

Operations Requiring Web Workers

Operation Threshold Worker Implementation
Excel Import (PO/Vendor) > 1,000 rows excel-parser.worker.ts
Excel Export (Reports) > 5,000 rows excel-export.worker.ts
Data Transformation > 10,000 records data-transform.worker.ts
CSV Processing > 5MB file csv-parser.worker.ts
Bid Calculation (Auction) > 100 bids bid-calculator.worker.ts
// excel-parser.worker.ts
import * as XLSX from 'xlsx';

addEventListener('message', ({ data }) => {
  const workbook = XLSX.read(data.buffer, { type: 'array' });
  const sheet = workbook.Sheets[workbook.SheetNames[0]];
  const jsonData = XLSX.utils.sheet_to_json(sheet);

  // Post processed data back to main thread
  postMessage({ success: true, data: jsonData });
});

// Component using worker
@Component({ ... })
export class ImportComponent {
  private worker: Worker;

  processExcel(file: File) {
    if (typeof Worker !== 'undefined') {
      this.worker = new Worker(
        new URL('./excel-parser.worker', import.meta.url)
      );

      this.worker.onmessage = ({ data }) => {
        if (data.success) {
          this.processImportedData(data.data);
        }
        this.worker.terminate(); // Clean up worker
      };

      file.arrayBuffer().then(buffer => {
        this.worker.postMessage({ buffer });
      });
    }
  }
}

SignalR Connection Management

SignalR provides real-time updates for reverse auctions and notifications. Improper connection management can lead to multiple connections and memory leaks.

App Init
Single Connection
Subscribe
Auction/Notifications
Live Updates
Throttled 100ms
Unsubscribe
On Navigate Away
Disconnect
On Logout
@Injectable({ providedIn: 'root' })
export class SignalRService implements OnDestroy {
  private hubConnection: HubConnection | null = null;
  private subscriptions = new Map<string, () => void>();

  // Singleton connection - created once per session
  async connect(): Promise<void> {
    if (this.hubConnection) return; // Already connected

    this.hubConnection = new HubConnectionBuilder()
      .withUrl('/hubs/auction')
      .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
      .build();

    await this.hubConnection.start();
  }

  // Subscribe with automatic cleanup tracking
  subscribeToAuction(auctionId: string, callback: (bid: Bid) => void) {
    const methodName = `ReceiveBid_${auctionId}`;

    // Throttle updates to prevent UI thrashing
    const throttledCallback = throttle(callback, 100);
    this.hubConnection?.on(methodName, throttledCallback);

    // Track for cleanup
    this.subscriptions.set(methodName, () => {
      this.hubConnection?.off(methodName);
    });
  }

  // Cleanup specific subscription
  unsubscribeFromAuction(auctionId: string) {
    const methodName = `ReceiveBid_${auctionId}`;
    this.subscriptions.get(methodName)?.();
    this.subscriptions.delete(methodName);
  }

  // Full cleanup on logout
  ngOnDestroy() {
    this.subscriptions.forEach(cleanup => cleanup());
    this.hubConnection?.stop();
  }
}

Business Rule: BR-MEM-012

Only ONE SignalR connection per browser session. Multiple connections waste resources and can cause duplicate event handling.

Memory Monitoring

Monitor memory usage during development to catch leaks early. Chrome provides performance.memory API for tracking heap size.

Memory Thresholds

Metric Warning Critical Action
JS Heap Size 500MB 1GB Show warning, suggest refresh
DOM Node Count 10,000 50,000 Log warning, investigate
Event Listeners 1,000 5,000 Audit and cleanup
Memory Growth Rate 25MB/min 50MB/min Trigger leak investigation
@Injectable({ providedIn: 'root' })
export class MemoryMonitorService {
  private lastHeapSize = 0;

  startMonitoring(intervalMs = 30000) {
    if (!environment.production && performance.memory) {
      setInterval(() => {
        const { usedJSHeapSize, jsHeapSizeLimit } = performance.memory;
        const usagePercent = (usedJSHeapSize / jsHeapSizeLimit) * 100;
        const growth = usedJSHeapSize - this.lastHeapSize;

        // Warning thresholds
        if (usagePercent > 70) {
          console.warn('Memory usage exceeds 70%', {
            used: this.formatBytes(usedJSHeapSize),
            limit: this.formatBytes(jsHeapSizeLimit)
          });
        }

        // Leak detection: > 50MB growth per interval
        if (growth > 50 * 1024 * 1024) {
          console.error('Potential memory leak detected!', {
            growth: this.formatBytes(growth)
          });
        }

        this.lastHeapSize = usedJSHeapSize;
      }, intervalMs);
    }
  }

  private formatBytes(bytes: number): string {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
  }
}

Development Tools

  • Chrome DevTools Memory Tab: Heap snapshots
  • Angular DevTools: Component tree inspection
  • Performance Observer API: Runtime monitoring
  • NgRx DevTools: Store size tracking

Testing Requirements

  • Memory leak tests on CI for subscription components
  • 1-hour session simulation for growth detection
  • Load tests with 500 concurrent users
  • Heap snapshot comparison before/after navigation

Chart.js Instance Cleanup

Chart.js instances hold references to canvas elements and data. Failing to destroy charts causes significant memory leaks.

@Component({
  selector: 'app-spend-chart',
  template: '<canvas #chartCanvas></canvas>'
})
export class SpendChartComponent implements OnDestroy {
  @ViewChild('chartCanvas') chartCanvas!: ElementRef<HTMLCanvasElement>;
  private chart: Chart | null = null;

  createChart(data: ChartData) {
    // Destroy existing chart before creating new one
    this.chart?.destroy();

    this.chart = new Chart(this.chartCanvas.nativeElement, {
      type: 'doughnut',
      data: data,
      options: { ... }
    });
  }

  ngOnDestroy() {
    // CRITICAL: Always destroy chart on component destroy
    this.chart?.destroy();
    this.chart = null;
  }
}

Business Rule: BR-MEM-019

Chart.js instances MUST call .destroy() in ngOnDestroy(). Each undestroyed chart can leak 5-20MB.

Business Rules Summary

Rule ID Category Description
BR-MEM-001 RxJS Every subscribe() call MUST have a corresponding cleanup strategy
BR-MEM-002 RxJS Services holding subscriptions MUST implement OnDestroy
BR-MEM-003 RxJS Shared observables MUST use shareReplay with refCount: true
BR-MEM-004 RxJS Component subscriptions MUST use takeUntilDestroyed() pattern
BR-MEM-005 NgRx Lists exceeding 100 items MUST implement virtual scrolling
BR-MEM-006 NgRx Module feature states MUST be cleared on lazy-loaded module unload
BR-MEM-007 NgRx NgRx selectors MUST use createSelector for memoization
BR-MEM-008 Virtual Scroll Lists rendering > 100 items MUST use virtual scrolling
BR-MEM-009 Virtual Scroll Virtual scroll buffer MUST maintain 10 items above/below viewport
BR-MEM-010 Web Workers Excel parsing for files > 1000 rows MUST use Web Workers
BR-MEM-011 Web Workers Heavy computations blocking UI > 100ms MUST be offloaded to Web Workers
BR-MEM-012 SignalR Only ONE SignalR connection per browser session
BR-MEM-013 SignalR SignalR subscriptions MUST be cleaned on component destroy
BR-MEM-014 SignalR Reverse auction bid updates MUST throttle UI rendering to 100ms
BR-MEM-015 Monitoring Memory monitoring MUST be enabled in development builds
BR-MEM-016 Monitoring Memory leaks causing > 50MB growth per minute MUST trigger investigation
BR-MEM-017 Monitoring Production builds SHOULD include lightweight memory telemetry
BR-MEM-018 Assets Images below viewport MUST use lazy loading
BR-MEM-019 Charts Chart.js instances MUST call .destroy() on component unmount
BR-MEM-020 Assets Large images MUST be served via CDN with appropriate caching