Memoization & Caching
13 min read•Frontend Patterns & Concepts
Memoization & Caching
Memoization is an optimization technique that stores the results of expensive function calls and returns the cached result when the same inputs occur again. It's the foundation of performance optimization in modern frontend development.
Why Memoization Matters
Every computation costs time. When you perform the same calculation repeatedly with identical inputs, you're wasting CPU cycles. This is especially problematic in:
- React components that re-render frequently
- Complex data transformations on large datasets
- Recursive algorithms with overlapping subproblems
- API response processing that doesn't change often
Basic Memoization Implementation
Simple Memoize Function
JAVASCRIPT
function memoize(fn) {
const cache = new Map();
return function memoized(...args) {
// Create a cache key from arguments
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Usage
const expensiveCalculation = memoize((n) => {
console.log(`Computing for ${n}...`);
let result = 0;
for (let i = 0; i < n * 1000000; i++) {
result += Math.sqrt(i);
}
return result;
});
expensiveCalculation(100); // Computes
expensiveCalculation(100); // Returns cached result
expensiveCalculation(200); // Computes (different input)
expensiveCalculation(100); // Returns cached resultThe Problem with JSON.stringify
JAVASCRIPT
// JSON.stringify has issues:
// 1. Object key order matters (but shouldn't)
JSON.stringify({ a: 1, b: 2 }); // '{"a":1,"b":2}'
JSON.stringify({ b: 2, a: 1 }); // '{"b":2,"a":1}' - different!
// 2. Can't handle circular references
const obj = { name: "test" };
obj.self = obj;
JSON.stringify(obj); // Throws!
// 3. Functions and undefined are lost
JSON.stringify({ fn: () => {}, val: undefined }); // '{}'
// 4. Slow for large objectsBetter Key Generation
JAVASCRIPT
function memoize(fn, options = {}) {
const {
maxSize = 100,
keyGenerator = defaultKeyGenerator,
ttl = null,
} = options;
const cache = new Map();
function defaultKeyGenerator(args) {
// Handle primitives quickly
if (args.length === 1) {
const arg = args[0];
const type = typeof arg;
if (type === "string" || type === "number" || type === "boolean") {
return `${type}:${arg}`;
}
}
// For objects, use WeakMap approach or stable stringify
return stableStringify(args);
}
return function memoized(...args) {
const key = keyGenerator(args);
if (cache.has(key)) {
const cached = cache.get(key);
// Check TTL if set
if (ttl && Date.now() - cached.timestamp > ttl) {
cache.delete(key);
} else {
// Move to end (LRU behavior)
cache.delete(key);
cache.set(key, cached);
return cached.value;
}
}
const result = fn.apply(this, args);
// Enforce max size (LRU eviction)
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, {
value: result,
timestamp: Date.now(),
});
return result;
};
}
// Stable stringify that handles edge cases
function stableStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
// Sort object keys for consistent ordering
if (!Array.isArray(value)) {
return Object.keys(value)
.sort()
.reduce((sorted, k) => {
sorted[k] = value[k];
return sorted;
}, {});
}
}
if (typeof value === "function") {
return `[Function:${value.name || "anonymous"}]`;
}
return value;
});
}Memoization with WeakMap for Object Arguments
When arguments are objects, use WeakMap to avoid memory leaks:
JAVASCRIPT
function memoizeWithObjects(fn) {
// First-level cache for the first argument
const cache = new WeakMap();
return function memoized(obj, ...rest) {
if (!cache.has(obj)) {
cache.set(obj, new Map());
}
const objCache = cache.get(obj);
const restKey = JSON.stringify(rest);
if (objCache.has(restKey)) {
return objCache.get(restKey);
}
const result = fn.call(this, obj, ...rest);
objCache.set(restKey, result);
return result;
};
}
// Usage - cache automatically cleans up when objects are garbage collected
const processUser = memoizeWithObjects((user, options) => {
return expensiveTransformation(user, options);
});
let user = { id: 1, name: "John" };
processUser(user, { format: "full" }); // Computed
processUser(user, { format: "full" }); // Cached
user = null; // Cache entry can be garbage collectedReal-World Memoization Patterns
1. Selector Memoization (Redux-style)
JAVASCRIPT
function createSelector(...args) {
const resultFn = args.pop();
const dependencies = args;
let lastArgs = null;
let lastResult = null;
return function selector(state) {
// Get current values from dependencies
const currentArgs = dependencies.map((dep) => dep(state));
// Check if any dependency changed
const hasChanged =
lastArgs === null || currentArgs.some((arg, i) => arg !== lastArgs[i]);
if (hasChanged) {
lastResult = resultFn(...currentArgs);
lastArgs = currentArgs;
}
return lastResult;
};
}
// Usage
const selectUsers = (state) => state.users;
const selectFilter = (state) => state.filter;
const selectFilteredUsers = createSelector(
selectUsers,
selectFilter,
(users, filter) => {
console.log("Recomputing filtered users...");
return users.filter((user) => user.name.includes(filter));
},
);
// Only recomputes when users or filter actually change
const state1 = { users: [{ name: "John" }], filter: "Jo", other: 1 };
selectFilteredUsers(state1); // Computes
const state2 = { ...state1, other: 2 }; // Only 'other' changed
selectFilteredUsers(state2); // Returns cached (dependencies unchanged)2. Recursive Memoization (Fibonacci Example)
JAVASCRIPT
// Without memoization - O(2^n)
function fibSlow(n) {
if (n <= 1) return n;
return fibSlow(n - 1) + fibSlow(n - 2);
}
// With memoization - O(n)
function createMemoizedFib() {
const cache = new Map();
function fib(n) {
if (n <= 1) return n;
if (cache.has(n)) {
return cache.get(n);
}
const result = fib(n - 1) + fib(n - 2);
cache.set(n, result);
return result;
}
return fib;
}
const fib = createMemoizedFib();
fib(50); // Instant
fibSlow(50); // Takes forever (don't run this!)3. API Response Caching
JAVASCRIPT
class APICache {
constructor(options = {}) {
this.cache = new Map();
this.ttl = options.ttl || 5 * 60 * 1000; // 5 minutes default
this.maxSize = options.maxSize || 100;
}
getCacheKey(url, options = {}) {
return JSON.stringify({ url, ...options });
}
get(url, options) {
const key = this.getCacheKey(url, options);
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set(url, options, data) {
const key = this.getCacheKey(url, options);
// LRU eviction
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, {
data,
expiresAt: Date.now() + this.ttl,
timestamp: Date.now(),
});
}
invalidate(pattern) {
if (typeof pattern === "string") {
// Delete exact match
this.cache.forEach((_, key) => {
if (key.includes(pattern)) {
this.cache.delete(key);
}
});
} else if (pattern instanceof RegExp) {
this.cache.forEach((_, key) => {
if (pattern.test(key)) {
this.cache.delete(key);
}
});
}
}
clear() {
this.cache.clear();
}
}
// Create a cached fetch wrapper
function createCachedFetch(cacheOptions = {}) {
const cache = new APICache(cacheOptions);
return async function cachedFetch(url, options = {}) {
const { cache: useCache = true, ...fetchOptions } = options;
if (useCache && fetchOptions.method === "GET") {
const cached = cache.get(url, fetchOptions);
if (cached) {
return cached;
}
}
const response = await fetch(url, fetchOptions);
const data = await response.json();
if (useCache && fetchOptions.method === "GET" && response.ok) {
cache.set(url, fetchOptions, data);
}
return data;
};
}
// Usage
const api = createCachedFetch({ ttl: 60000 });
await api("/api/users"); // Network request
await api("/api/users"); // Cached
await api("/api/users", { cache: false }); // Force fresh4. Computed Property Memoization
JAVASCRIPT
function memoizeMethod(target, propertyKey, descriptor) {
const originalMethod = descriptor.value;
const cacheKey = Symbol(`__memoized_${propertyKey}`);
descriptor.value = function (...args) {
if (!this[cacheKey]) {
this[cacheKey] = new Map();
}
const key = JSON.stringify(args);
if (this[cacheKey].has(key)) {
return this[cacheKey].get(key);
}
const result = originalMethod.apply(this, args);
this[cacheKey].set(key, result);
return result;
};
return descriptor;
}
// Usage with decorator (requires TypeScript or Babel)
class DataProcessor {
constructor(data) {
this.data = data;
}
@memoizeMethod
processExpensively(config) {
console.log("Processing...");
// Expensive operation
return this.data.map((item) => transform(item, config));
}
}
// Or without decorators
class DataProcessorNoDecorator {
constructor(data) {
this.data = data;
this.processExpensively = memoize(this._processExpensively.bind(this));
}
_processExpensively(config) {
// Expensive operation
return this.data.map((item) => transform(item, config));
}
}React-Specific Memoization
useMemo Hook
JAVASCRIPT
import { useMemo, useState } from "react";
function ExpensiveComponent({ items, filter, sortBy }) {
// Only recompute when dependencies change
const processedItems = useMemo(() => {
console.log("Processing items...");
return items
.filter((item) => item.name.includes(filter))
.sort((a, b) => a[sortBy] - b[sortBy])
.map((item) => ({
...item,
computed: expensiveCalculation(item),
}));
}, [items, filter, sortBy]);
return (
<ul>
{processedItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}useCallback Hook
JAVASCRIPT
import { useCallback, useState } from "react";
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("");
// Without useCallback - new function every render
// const handleClick = () => {
// console.log(name);
// };
// With useCallback - stable reference
const handleClick = useCallback(() => {
console.log(name);
}, [name]); // Only recreate when name changes
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<ExpensiveChild onClick={handleClick} />
<button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
</>
);
}
// memo prevents re-render if props haven't changed
const ExpensiveChild = React.memo(function ExpensiveChild({ onClick }) {
console.log("ExpensiveChild rendered");
// ... expensive render
return <button onClick={onClick}>Click me</button>;
});React.memo with Custom Comparison
JAVASCRIPT
function areEqual(prevProps, nextProps) {
// Custom comparison logic
// Return true if props are equal (skip re-render)
// Return false if props are different (re-render)
// Shallow comparison for most props
for (let key in prevProps) {
if (key === "data") {
// Deep comparison for data
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
}
if (prevProps[key] !== nextProps[key]) {
return false;
}
}
return true;
}
const MemoizedComponent = React.memo(function Component({ data, onClick }) {
// Expensive render
return <div>{/* ... */}</div>;
}, areEqual);Custom Hook for Memoized Async Data
JAVASCRIPT
import { useState, useEffect, useRef, useCallback } from "react";
function useMemoizedFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const cache = useRef(new Map());
const { ttl = 60000, cacheKey = url } = options;
const fetchData = useCallback(
async (forceRefresh = false) => {
const cached = cache.current.get(cacheKey);
if (!forceRefresh && cached && Date.now() - cached.timestamp < ttl) {
setData(cached.data);
return cached.data;
}
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Fetch failed");
const result = await response.json();
cache.current.set(cacheKey, {
data: result,
timestamp: Date.now(),
});
setData(result);
return result;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
},
[url, cacheKey, ttl],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const refresh = useCallback(() => fetchData(true), [fetchData]);
return { data, loading, error, refresh };
}
// Usage
function UserProfile({ userId }) {
const {
data: user,
loading,
error,
refresh,
} = useMemoizedFetch(`/api/users/${userId}`, {
ttl: 5 * 60 * 1000,
cacheKey: `user-${userId}`,
});
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{user.name}</h1>
<button onClick={refresh}>Refresh</button>
</div>
);
}Cache Invalidation Strategies
Time-Based (TTL)
JAVASCRIPT
class TTLCache {
constructor(defaultTTL = 60000) {
this.cache = new Map();
this.defaultTTL = defaultTTL;
}
set(key, value, ttl = this.defaultTTL) {
const expiresAt = Date.now() + ttl;
this.cache.set(key, { value, expiresAt });
// Auto-cleanup
setTimeout(() => {
this.delete(key);
}, ttl);
}
get(key) {
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
delete(key) {
this.cache.delete(key);
}
}Event-Based Invalidation
JAVASCRIPT
class EventCache {
constructor() {
this.cache = new Map();
this.dependencies = new Map(); // key -> Set of event names
}
set(key, value, dependsOn = []) {
this.cache.set(key, value);
dependsOn.forEach((event) => {
if (!this.dependencies.has(event)) {
this.dependencies.set(event, new Set());
}
this.dependencies.get(event).add(key);
});
}
get(key) {
return this.cache.get(key);
}
invalidate(event) {
const keys = this.dependencies.get(event);
if (keys) {
keys.forEach((key) => this.cache.delete(key));
keys.clear();
}
}
}
// Usage
const cache = new EventCache();
cache.set("userList", users, ["user:created", "user:updated", "user:deleted"]);
cache.set("userCount", users.length, ["user:created", "user:deleted"]);
// When a user is created, invalidate relevant caches
function createUser(userData) {
// ... create user
cache.invalidate("user:created");
}Tag-Based Invalidation
JAVASCRIPT
class TaggedCache {
constructor() {
this.cache = new Map();
this.tags = new Map(); // tag -> Set of keys
}
set(key, value, tags = []) {
this.cache.set(key, value);
tags.forEach((tag) => {
if (!this.tags.has(tag)) {
this.tags.set(tag, new Set());
}
this.tags.get(tag).add(key);
});
}
get(key) {
return this.cache.get(key);
}
invalidateByTag(tag) {
const keys = this.tags.get(tag);
if (keys) {
keys.forEach((key) => {
this.cache.delete(key);
// Clean up tag references
this.tags.forEach((tagKeys) => tagKeys.delete(key));
});
this.tags.delete(tag);
}
}
invalidateByTags(tags) {
tags.forEach((tag) => this.invalidateByTag(tag));
}
}
// Usage
const cache = new TaggedCache();
cache.set("user:1", userData, ["users", "user:1"]);
cache.set("user:2", userData2, ["users", "user:2"]);
cache.set("posts:user:1", posts, ["posts", "user:1"]);
// Invalidate all caches related to user 1
cache.invalidateByTag("user:1"); // Removes 'user:1' and 'posts:user:1'LRU (Least Recently Used) Cache
JAVASCRIPT
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) {
return undefined;
}
// Move to end (most recently used)
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
// If key exists, delete first (to update position)
if (this.cache.has(key)) {
this.cache.delete(key);
}
// Evict oldest if at capacity
while (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
has(key) {
return this.cache.has(key);
}
delete(key) {
return this.cache.delete(key);
}
clear() {
this.cache.clear();
}
get size() {
return this.cache.size;
}
}Common Pitfalls and Solutions
1. Stale Closures
JAVASCRIPT
// ❌ Problem: Stale closure captures old value
function BrokenComponent() {
const [count, setCount] = useState(0);
const memoizedLog = useMemo(() => {
return () => console.log(count); // Captures count at creation time
}, []); // Empty deps means it never updates
return <button onClick={memoizedLog}>Log Count</button>;
}
// ✅ Solution: Include in dependencies
function FixedComponent() {
const [count, setCount] = useState(0);
const memoizedLog = useCallback(() => {
console.log(count);
}, [count]); // Updates when count changes
return <button onClick={memoizedLog}>Log Count</button>;
}2. Object/Array Reference Changes
JAVASCRIPT
// ❌ Problem: New object reference every render
function BrokenList({ items }) {
const config = { showDetails: true }; // New object each render
return items.map((item) => (
<MemoizedItem key={item.id} item={item} config={config} />
));
}
// ✅ Solution: Memoize or lift out
const CONFIG = { showDetails: true }; // Stable reference
function FixedList({ items }) {
const config = useMemo(() => ({ showDetails: true }), []);
return items.map((item) => (
<MemoizedItem key={item.id} item={item} config={config} />
));
}3. Over-Memoization
JAVASCRIPT
// ❌ Unnecessary memoization - simple computation
const doubled = useMemo(() => count * 2, [count]);
// ✅ Just compute directly
const doubled = count * 2;
// ✅ DO memoize when:
// - Computation is expensive (filtering/sorting large arrays)
// - Result is passed to memoized children
// - Result is used in dependency arraysSummary
| Pattern | Use Case | Key Benefit |
|---|---|---|
| Basic memoize | Pure functions with expensive computation | Avoid recomputation |
| Selector memoization | Derived state from store | Prevent cascade re-renders |
| API caching | Network requests | Reduce bandwidth, improve UX |
| useMemo | Expensive renders | Skip unnecessary work |
| useCallback | Event handlers | Stable references for children |
| React.memo | Component renders | Skip re-renders on unchanged props |
| LRU Cache | Limited memory | Automatic eviction of old entries |
Golden Rule: Profile first, memoize second. Premature optimization can add complexity without meaningful benefit.