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.
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.
takeUntilDestroyed() for all subscriptionsngOnDestroy()
Every subscribe() call creates a subscription that must be cleaned up. Angular 21 provides
takeUntilDestroyed() from @angular/core/rxjs-interop as the preferred pattern.
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()); } }
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); }
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! // ); }
Every subscribe() call MUST have a corresponding cleanup strategy (takeUntilDestroyed(), async pipe, or explicit unsubscribe()).
NgRx store can accumulate large amounts of data over time. Implement pagination, cleanup on navigation, and memoized selectors to prevent memory bloat.
| 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; } }
Module feature states MUST be cleared when leaving lazy-loaded modules to prevent stale data accumulation.
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.
| 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; }
Virtual scrolling reduces DOM nodes from 10,000+ to ~50 for a list of 10,000 items, saving ~95% memory.
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.
| 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 provides real-time updates for reverse auctions and notifications. Improper connection management can lead to multiple connections and memory leaks.
@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(); } }
Only ONE SignalR connection per browser session. Multiple connections waste resources and can cause duplicate event handling.
Monitor memory usage during development to catch leaks early. Chrome provides performance.memory
API for tracking heap size.
| 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'; } }
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; } }
Chart.js instances MUST call .destroy() in ngOnDestroy(). Each undestroyed chart can leak 5-20MB.
| 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 |