Skip to main content

Memoization in Angular and Pure Pipes

🧠 What is Memoization?

Memoization is an optimization technique where you cache the results of expensive function calls and return the cached result when the same inputs occur again.

// Basic memoization example
function memoize(fn) {
const cache = new Map();

return function(...args) {
const key = JSON.stringify(args);

if (cache.has(key)) {
console.log('Cache hit!');
return cache.get(key); // Return cached result
}

console.log('Computing...');
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}

// Usage
const expensiveFunction = (n) => {
let sum = 0;
for (let i = 0; i < n * 1000000; i++) {
sum += i;
}
return sum;
};

const memoizedFunction = memoize(expensiveFunction);
console.log(memoizedFunction(5)); // Computing... (takes time)
console.log(memoizedFunction(5)); // Cache hit! (instant)

🔧 Pure Pipes: Angular's Built-in Memoization

Yes, Angular's pure pipes ARE a form of memoization! They cache results based on input reference equality.

How Pure Pipes Work (Memoization Behavior)

@Pipe({
name: 'uppercase',
pure: true // This is the default - enables memoization behavior
})
export class UppercasePipe implements PipeTransform {
transform(value: string): string {
console.log('Transform called for:', value);
return value.toUpperCase();
}
}

Pure Pipe Memoization in Action

@Component({
selector: 'app-demo',
template: `
<div>
<p>{{ message | uppercase }}</p>
<p>{{ message | uppercase }}</p>
<p>{{ message | uppercase }}</p>
<button (click)="updateMessage()">Update Message</button>
<button (click)="triggerChangeDetection()">Trigger CD</button>
</div>
`
})
export class DemoComponent {
message = 'hello world';

updateMessage() {
this.message = 'new message'; // Reference change - pipe will run
}

triggerChangeDetection() {
// Force change detection without changing message
// Pure pipe won't run because message reference is same
}
}

Console output when component loads:

Transform called for: hello world  // Only called once!

When you click "Trigger CD" multiple times:

// No console output - pipe result is cached/memoized!

When you click "Update Message":

Transform called for: new message  // Called again due to new input

🔍 Pure vs Impure Pipes: Memoization Comparison

Pure Pipe (Built-in Memoization)

@Pipe({
name: 'purePipe',
pure: true // Memoization enabled
})
export class PurePipe implements PipeTransform {
transform(value: string): string {
console.log('Pure pipe called');
return value.toUpperCase();
}
}

Behavior:

  • Memoized: Only runs when input reference changes
  • Performance: Cached results for same inputs
  • Automatic: No manual cache management needed

Impure Pipe (No Memoization)

@Pipe({
name: 'impurePipe',
pure: false // No memoization - runs on every change detection
})
export class ImpurePipe implements PipeTransform {
transform(value: string): string {
console.log('Impure pipe called'); // Called on EVERY change detection
return value.toUpperCase();
}
}

Behavior:

  • Not Memoized: Runs on every change detection cycle
  • Performance: No caching - recalculates every time
  • ⚠️ Use Case: When you need to detect internal object changes

🧮 Angular Signals: Computed Memoization

Angular Signals also provide built-in memoization through computed signals! The computed signal value is memoized, meaning it stores the computed result. That computed value is reused the next time the computed value is read.

Computed Signal Memoization Example

import { Component, signal, computed } from '@angular/core';

@Component({
selector: 'app-signal-memoization',
template: `
<div>
<h3>Count: {{ count() }}</h3>
<h3>Expensive Result: {{ expensiveComputation() }}</h3>
<h3>Reading again: {{ expensiveComputation() }}</h3>
<h3>And again: {{ expensiveComputation() }}</h3>

<button (click)="increment()">Increment</button>
<button (click)="readAgain()">Read Computed Again</button>
</div>
`
})
export class SignalMemoizationComponent {
count = signal(0);

// ✅ Computed signal with memoization
expensiveComputation = computed(() => {
console.log('🔥 Expensive computation running...'); // Only logs when count changes!

// Simulate expensive operation
let result = 0;
for (let i = 0; i < this.count() * 1000000; i++) {
result += i;
}

return `Result: ${result}`;
});

increment() {
this.count.update(n => n + 1);
// This will trigger recomputation because count changed
}

readAgain() {
// These reads won't trigger recomputation - memoized value is returned
console.log('Reading 1:', this.expensiveComputation());
console.log('Reading 2:', this.expensiveComputation());
console.log('Reading 3:', this.expensiveComputation());
}
}

Console behavior:

// Initial render
🔥 Expensive computation running...

// Multiple template reads ({{ expensiveComputation() }})
// (No additional console output - memoized!)

// Click "Read Computed Again"
Reading 1: Result: 0
Reading 2: Result: 0
Reading 3: Result: 0
// (No "Expensive computation running..." - memoized!)

// Click "Increment"
🔥 Expensive computation running... // Only NOW it recomputes

Computed Signal vs Function Call Comparison

@Component({
selector: 'app-memoization-comparison',
template: `
<div>
<h3>Count: {{ count() }}</h3>

<!-- ❌ Function call - NO memoization -->
<p>Function Result: {{ calculateExpensive() }}</p>
<p>Function Again: {{ calculateExpensive() }}</p>

<!-- ✅ Computed signal - WITH memoization -->
<p>Computed Result: {{ expensiveComputed() }}</p>
<p>Computed Again: {{ expensiveComputed() }}</p>

<button (click)="increment()">Increment</button>
</div>
`
})
export class MemoizationComparisonComponent {
count = signal(1);

// ❌ Regular function - NO memoization (runs every time)
calculateExpensive(): string {
console.log('🔴 Function: Expensive calculation running...');

let result = 0;
for (let i = 0; i < this.count() * 1000000; i++) {
result += i;
}

return `Function: ${result}`;
}

// ✅ Computed signal - WITH memoization (cached result)
expensiveComputed = computed(() => {
console.log('🟢 Computed: Expensive calculation running...');

let result = 0;
for (let i = 0; i < this.count() * 1000000; i++) {
result += i;
}

return `Computed: ${result}`;
});

increment() {
this.count.update(n => n + 1);
}
}

Console behavior on initial render:

🔴 Function: Expensive calculation running...
🔴 Function: Expensive calculation running... // Called TWICE!
🟢 Computed: Expensive calculation running... // Called ONCE, then memoized

Complex Computed Signal Memoization

@Component({
selector: 'app-complex-memoization',
template: `
<div>
<h3>Items: {{ items().length }}</h3>
<h3>Filter: {{ filter() }}</h3>

<!-- All these read the same computed signal - only calculated once -->
<p>Filtered Count: {{ filteredItems().length }}</p>
<p>First Item: {{ filteredItems()[0]?.name || 'None' }}</p>
<p>Total Value: {{ totalValue() }}</p>
<p>Average Value: {{ averageValue() }}</p>

<button (click)="addItem()">Add Item</button>
<button (click)="changeFilter()">Change Filter</button>
</div>
`
})
export class ComplexMemoizationComponent {
items = signal([
{ id: 1, name: 'Item 1', value: 100, category: 'A' },
{ id: 2, name: 'Item 2', value: 200, category: 'B' },
{ id: 3, name: 'Item 3', value: 150, category: 'A' }
]);

filter = signal('A');

// ✅ Memoized computed signal
filteredItems = computed(() => {
console.log('🔄 Computing filtered items...');
return this.items().filter(item => item.category === this.filter());
});

// ✅ Computed based on other computed - also memoized
totalValue = computed(() => {
console.log('💰 Computing total value...');
return this.filteredItems().reduce((sum, item) => sum + item.value, 0);
});

// ✅ Another dependent computed - memoized
averageValue = computed(() => {
console.log('📊 Computing average value...');
const items = this.filteredItems();
return items.length > 0 ? this.totalValue() / items.length : 0;
});

addItem() {
const newItem = {
id: Date.now(),
name: `Item ${this.items().length + 1}`,
value: Math.floor(Math.random() * 300) + 50,
category: Math.random() > 0.5 ? 'A' : 'B'
};
this.items.update(items => [...items, newItem]);
}

changeFilter() {
this.filter.update(f => f === 'A' ? 'B' : 'A');
}
}

📊 Demonstrating Pure Pipe Memoization

Example: Array Processing

@Component({
selector: 'app-array-demo',
template: `
<div>
<!-- Pure pipe - memoized behavior -->
<p>Sum: {{ numbers | sum }}</p>
<p>Sum again: {{ numbers | sum }}</p>

<button (click)="addNumber()">Add Number</button>
<button (click)="changeReference()">Change Reference</button>
<button (click)="forceCD()">Force Change Detection</button>
</div>
`
})
export class ArrayDemoComponent {
numbers = [1, 2, 3, 4, 5];

addNumber() {
// ❌ This WON'T trigger pure pipe (same array reference)
this.numbers.push(Math.floor(Math.random() * 10));
}

changeReference() {
// ✅ This WILL trigger pure pipe (new array reference)
this.numbers = [...this.numbers, Math.floor(Math.random() * 10)];
}

forceCD() {
// Pure pipe won't run - result is memoized
}
}

@Pipe({
name: 'sum',
pure: true // Memoization enabled
})
export class SumPipe implements PipeTransform {
transform(numbers: number[]): number {
console.log('SumPipe: Calculating sum...'); // Watch when this runs
return numbers.reduce((sum, num) => sum + num, 0);
}
}

Console behavior:

// Initial load
SumPipe: Calculating sum...

// Click "Force Change Detection" multiple times
// (No console output - memoized!)

// Click "Add Number"
// (No console output - same reference, memoized!)

// Click "Change Reference"
SumPipe: Calculating sum... // Runs because new array reference

🚀 Manual Memoization in Angular

Custom Memoized Pipe

@Pipe({
name: 'memoizedCalculation',
pure: false // We handle memoization manually
})
export class MemoizedCalculationPipe implements PipeTransform {
private cache = new Map<string, any>();

transform(data: any[], operation: string): any {
// Create cache key from inputs
const cacheKey = this.createCacheKey(data, operation);

// Check cache first (memoization)
if (this.cache.has(cacheKey)) {
console.log('Manual cache hit!');
return this.cache.get(cacheKey);
}

// Perform expensive calculation
console.log('Manual calculation...');
const result = this.performCalculation(data, operation);

// Store in cache
this.cache.set(cacheKey, result);
return result;
}

private createCacheKey(data: any[], operation: string): string {
return `${operation}-${JSON.stringify(data)}`;
}

private performCalculation(data: any[], operation: string): any {
// Simulate expensive operation
switch (operation) {
case 'sum':
return data.reduce((sum, item) => sum + (item.value || item), 0);
case 'average':
const total = data.reduce((sum, item) => sum + (item.value || item), 0);
return total / data.length;
case 'max':
return Math.max(...data.map(item => item.value || item));
default:
return data;
}
}
}

Advanced Memoization with LRU Cache

class LRUCache<K, V> {
private cache = new Map<K, V>();
private maxSize: number;

constructor(maxSize: number = 50) {
this.maxSize = maxSize;
}

get(key: K): V | undefined {
if (this.cache.has(key)) {
// Move to end (most recently used)
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
return undefined;
}

set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}

@Pipe({
name: 'lruMemoized',
pure: false
})
export class LRUMemoizedPipe implements PipeTransform, OnDestroy {
private cache = new LRUCache<string, any>(100);

transform(data: any, operation: string): any {
const cacheKey = `${operation}-${JSON.stringify(data)}`;

// Try to get from cache
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
console.log('LRU cache hit!');
return cached;
}

// Calculate new result
console.log('LRU calculation...');
const result = this.expensiveOperation(data, operation);

// Store in cache
this.cache.set(cacheKey, result);
return result;
}

private expensiveOperation(data: any, operation: string): any {
// Your expensive computation here
return data;
}

ngOnDestroy(): void {
this.cache = new LRUCache(100); // Clear cache
}
}

🎯 Memoization in Angular Services

Memoized HTTP Requests

@Injectable({
providedIn: 'root'
})
export class MemoizedDataService {
private cache = new Map<string, Observable<any>>();

constructor(private http: HttpClient) {}

getData(id: string): Observable<any> {
// Check if request is already cached
if (this.cache.has(id)) {
console.log('HTTP request cache hit!');
return this.cache.get(id)!;
}

// Make HTTP request and cache the observable
console.log('Making HTTP request...');
const request$ = this.http.get(`/api/data/${id}`).pipe(
shareReplay(1) // Share the result with multiple subscribers
);

this.cache.set(id, request$);
return request$;
}

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

Memoized Computation Service

@Injectable({
providedIn: 'root'
})
export class MemoizedComputationService {
private cache = new Map<string, any>();

fibonacci(n: number): number {
const key = `fibonacci-${n}`;

if (this.cache.has(key)) {
console.log('Fibonacci cache hit!');
return this.cache.get(key);
}

console.log('Computing fibonacci...');
const result = this.computeFibonacci(n);
this.cache.set(key, result);
return result;
}

private computeFibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}

⚡ Performance Comparison

Without Memoization (Impure Pipe)

@Pipe({
name: 'expensiveOperation',
pure: false // No memoization
})
export class ExpensiveOperationPipe implements PipeTransform {
transform(data: number[]): number {
console.log('Expensive calculation running...');

// Simulate expensive operation
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += data.reduce((sum, num) => sum + num, 0);
}

return result; // This runs on EVERY change detection!
}
}

With Memoization (Pure Pipe)

@Pipe({
name: 'memoizedExpensive',
pure: true // Built-in memoization
})
export class MemoizedExpensivePipe implements PipeTransform {
transform(data: number[]): number {
console.log('Expensive calculation running...');

// Same expensive operation
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += data.reduce((sum, num) => sum + num, 0);
}

return result; // Only runs when data reference changes!
}
}

With Computed Signals (Automatic Memoization)

@Component({
selector: 'app-performance-demo',
template: `
<div>
<h3>Data: {{ data().length }} items</h3>

<!-- ❌ Function call - recalculates every time -->
<p>Function: {{ calculateTotal() }}</p>

<!-- ✅ Pure pipe - memoized by reference -->
<p>Pipe: {{ data() | expensiveSum }}</p>

<!-- ✅ Computed signal - automatically memoized -->
<p>Computed: {{ computedTotal() }}</p>

<button (click)="addItem()">Add Item</button>
<button (click)="triggerCD()">Trigger Change Detection</button>
</div>
`
})
export class PerformanceDemoComponent {
data = signal([1, 2, 3, 4, 5]);

// ❌ Function - NO memoization
calculateTotal(): number {
console.log('🔴 Function: Calculating total...');
return this.data().reduce((sum, num) => sum + num, 0);
}

// ✅ Computed signal - WITH memoization
computedTotal = computed(() => {
console.log('🟢 Computed: Calculating total...');
return this.data().reduce((sum, num) => sum + num, 0);
});

addItem() {
this.data.update(arr => [...arr, Math.floor(Math.random() * 10)]);
}

triggerCD() {
// Force change detection without changing data
}
}

📋 Memoization Strategies Summary

StrategyWhen to UseProsConsMemoization Type
Pure PipesSimple transformations, immutable dataAutomatic, built-in, no code neededReference-based onlyReference equality
Computed SignalsReactive derived stateAutomatic, dependency tracking, built-inAngular 16+ onlyDependency-based
Manual MemoizationComplex caching logic, custom cache keysFull control, custom logicMore code, manual managementCustom key-based
LRU CacheLimited memory, frequently changing dataMemory efficient, automatic cleanupMore complex implementationSize-limited
Service MemoizationShared across components, HTTP requestsCentralized, reusableLifecycle management neededGlobal caching

🔑 Key Takeaways

  1. Pure pipes ARE memoization - they cache results based on input reference equality
  2. Computed signals provide automatic memoization - they store computed results and reuse them when dependencies haven't changed
  3. Pure pipes are automatic - Angular handles the caching for you
  4. Computed signals are reactive - they automatically track dependencies and only recompute when needed
  5. Impure pipes have no memoization - they run on every change detection
  6. Manual memoization gives more control - custom cache keys, TTL, size limits
  7. Use pure pipes for immutable data - they're optimized for reference changes
  8. Use computed signals for reactive derived state - they provide automatic dependency tracking
  9. Use manual memoization for complex scenarios - when you need custom caching logic
  10. Always consider memory management - clear caches when appropriate

Bottom Line: Angular provides multiple built-in memoization mechanisms:

  • Pure pipes for template transformations (reference-based memoization)
  • Computed signals for reactive derived state (dependency-based memoization)

Both demonstrate that memoization doesn't always require explicit cache management - sometimes it's built into the framework! 🚀