Caching with PromiseMaps
Delivering value to customers in real-time often means performing expensive
computations on the fly. A simple data structure called a PromiseMap
can help
reduce system load while maintaining responsiveness.
The Problem
Imagine a social network where popular user profiles get requested many times per second. A naive cache implementation might look like this:
const cache: Map<number, UserProfile> = new Map();
const getProfile = async (userId: number): Promise<UserProfile> => {
if (!cache.has(userId)) {
cache.set(userId, await db.getUserProfile(userId));
}
return cache.get(userId);
};
This has a critical flaw: the cache isn’t populated until after the await
completes. During this window, every concurrent request triggers a separate
database query: a “cache stampede.”
The Solution
Cache the promise, not the value:
const cache: Map<number, Promise<UserProfile>> = new Map();
const getProfile = async (userId: number): Promise<UserProfile> => {
if (!cache.has(userId)) {
cache.set(userId, db.getUserProfile(userId));
}
return cache.get(userId);
};
Now concurrent requests await the same promise. Only one database query occurs per unique key.
Why It Works
A PromiseMap
is just a Map<Key, Promise<Value>>
. This pattern works because
promises execute immediately when created. Multiple awaits on the same promise
return the same value, and once resolved promises cache their result permanently.
This makes refetching the same request cost practically nothing.
Error handling
Failed promises need cleanup to allow for retries:
cache.set(
userId,
db.getUserProfile(userId).catch((error) => {
cache.delete(userId); // Allow retry on failure
throw error;
}),
);
PromiseMaps solve cache stampedes by caching promises rather than values. While they require careful error handling and memory management, they can reduce backend load by orders of magnitude for frequently accessed resources.