Learning Guides
Menu

Memoization & Caching

13 min readFrontend 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 result

The 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 objects

Better 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 collected

Real-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 fresh

4. 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 arrays

Summary

PatternUse CaseKey Benefit
Basic memoizePure functions with expensive computationAvoid recomputation
Selector memoizationDerived state from storePrevent cascade re-renders
API cachingNetwork requestsReduce bandwidth, improve UX
useMemoExpensive rendersSkip unnecessary work
useCallbackEvent handlersStable references for children
React.memoComponent rendersSkip re-renders on unchanged props
LRU CacheLimited memoryAutomatic eviction of old entries

Golden Rule: Profile first, memoize second. Premature optimization can add complexity without meaningful benefit.