Memoization Patterns in Vanilla JS

Posted by

Speeding up expensive function calls with caching strategies you can write by hand

Speeding up expensive function calls with caching strategies you can write by hand

Introduction

Every developer has faced this: a function runs fine the first time, but when called repeatedly — especially with the same inputs — it becomes a performance bottleneck.

That’s where memoization comes in. Memoization is a fancy word for a simple idea: cache the results of function calls so you don’t recompute them unnecessarily.

You don’t need Lodash or fancy frameworks. With vanilla JavaScript, you can implement powerful memoization patterns that:

  • Prevent redundant work.
  • Speed up recursive algorithms (like Fibonacci).
  • Optimize data processing pipelines.
  • Improve responsiveness in UIs.

This article covers memoization patterns you can implement in plain JS, from the simplest cache to more advanced strategies like argument serialization, weak references, and custom eviction.


1) What Is Memoization (Plain English)?

Memoization = Caching function results.

// Without memoization
expensive(5); // takes 200ms
expensive(5); // takes 200ms again

// With memoization
memoizedExpensive(5); // takes 200ms
memoizedExpensive(5); // returns instantly from cache

Key parts of memoization:

  • Cache: A storage for past results.
  • Key: How you identify a call (usually the arguments).
  • Eviction: When/if to drop old results.

2) The Simplest Memoization Pattern

function memoize(fn) {
const cache = {};
return function(arg) {
if (cache[arg] !== undefined) {
return cache[arg];
}
const result = fn(arg);
cache[arg] = result;
return result;
};
}

// Example
const square = memoize(x => {
console.log("Computing", x);
return x * x;
});

console.log(square(4)); // Computing 4 → 16
console.log(square(4)); // 16 (cached)

Limitations:

  • Works only for single primitive argument.
  • Doesn’t handle objects or multiple args.

3) Multi-Argument Memoization

function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args); // simple serialization
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}

const add = memoize((a, b) => {
console.log("Computing", a, b);
return a + b;
});

add(2, 3); // Computing → 5
add(2, 3); // 5 (cached)

👉 Useful for most utilities.
⚠️ Caveat: JSON.stringify can fail for circular objects and treats {a:1,b:2}{b:2,a:1}.


4) Using Maps for Object Keys

Instead of serializing, use nested maps for argument-based caching.

function memoize(fn) {
const cache = new Map();
return function(...args) {
let current = cache;
for (const arg of args) {
if (!current.has(arg)) {
current.set(arg, new Map());
}
current = current.get(arg);
}
if (current.has("result")) {
return current.get("result");
}
const result = fn(...args);
current.set("result", result);
return result;
};
}

const concat = memoize((a, b) => {
console.log("Computing", a, b);
return a + b;
});

concat("Hello", "World"); // Computing → "HelloWorld"
concat("Hello", "World"); // "HelloWorld" (cached)

👉 Handles objects/arrays as arguments (because they can be Map keys).


5) Recursive Function Memoization

Classic example: Fibonacci.

function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = args[0]; // single-arg fib
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}

const fib = memoize(n => {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
});

console.log(fib(40)); // ~instant compared to naive recursion

6) WeakMap for Object Arguments

When arguments are objects, you might not want to prevent garbage collection. Use WeakMap.

function memoize(fn) {
const cache = new WeakMap();
return function(obj) {
if (cache.has(obj)) return cache.get(obj);
const result = fn(obj);
cache.set(obj, result);
return result;
};
}

// Example
const getId = memoize(user => user.id);
const user = { id: 42 };
console.log(getId(user)); // 42
console.log(getId(user)); // 42 (cached)

user = null; // object eligible for GC, cache entry disappears

👉 Great for expensive lookups keyed by object identity (e.g., DOM nodes, data objects).


7) Custom Cache Eviction (LRU Strategy)

Sometimes caches grow unbounded. Use Least Recently Used (LRU) eviction.

function memoize(fn, limit = 5) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
// refresh usage
const val = cache.get(key);
cache.delete(key);
cache.set(key, val);
return val;
}
const result = fn(...args);
cache.set(key, result);
if (cache.size > limit) {
// delete oldest
cache.delete(cache.keys().next().value);
}
return result;
};
}

// Example
const slowDouble = n => {
console.log("Computing", n);
return n * 2;
};

const double = memoize(slowDouble, 3);
double(1); double(2); double(3); double(4); // evicts oldest

👉 Useful for apps with limited memory or constantly changing inputs.


8) Async Function Memoization

Memoizing async calls avoids duplicate fetches.

function memoizeAsync(fn) {
const cache = new Map();
return async function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const promise = fn(...args).then(res => {
cache.set(key, res);
return res;
});
cache.set(key, promise);
return promise;
};
}

// Example
const fetchUser = memoizeAsync(async id => {
console.log("Fetching user", id);
return (await fetch(`/api/users/${id}`)).json();
});

fetchUser(1).then(console.log);
fetchUser(1).then(console.log); // second call reuses same promise

👉 Prevents duplicate requests when multiple calls happen simultaneously.


9) Practical Use Cases

  • Recursive math: Fibonacci, factorial, pathfinding.
  • DOM-heavy apps: Cache expensive lookups like getBoundingClientRect.
  • Data pipelines: Cache transforms (parsing JSON, compiling regexes).
  • APIs: Prevent duplicate network calls.
  • UI state: Avoid recomputing derived data (similar to React’s useMemo).

10) Common Pitfalls

  • Memory leaks: Don’t memoize with plain objects as keys (they’ll never be garbage-collected). Use WeakMap.
  • Wrong key strategy: JSON.stringify may treat {a:1,b:2} and {b:2,a:1} as different keys even if logically same.
  • Over-memoization: Memoizing cheap functions can add overhead instead of saving time.
  • Mutable args: If you pass mutable objects (like arrays), caching can return stale results.

11) Quick Reference Patterns


Conclusion

Memoization is one of those timeless optimization techniques that scales from algorithms to UI performance. With vanilla JS, you can implement it in a handful of lines, adapting patterns to your problem:

  • Use simple caches for primitives.
  • Use nested Maps/WeakMaps for object keys.
  • Add eviction (LRU) for long-running apps.
  • Use async memoization for network or I/O.

Pro tip: Memoization shines when the function is expensive and called repeatedly with the same inputs. Don’t memoize everything — be intentional.


Call to Action

What’s the trickiest function you’ve ever optimized with memoization — recursive Fibonacci, API calls, or UI rendering?

💬 Share your story in the comments.
🔖 Bookmark this as your memoization toolbox.
👩‍💻 Share with a teammate who still recalculates Fibonacci the naive way.

Leave a Reply

Your email address will not be published. Required fields are marked *