./ahmedhashim

Caching with PromiseMaps

Most cache structures are written as Map<Key, Value>. However, for anything that gets hit concurrently, the more useful shape is actually Map<Key, Promise<Value>>. Wrapping the value in a promise helps to avoid a whole class of stampede bugs.

The problem

On a social network where popular user profiles get fetched hundreds of times per second, a naive cache 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);
};

The flaw is the gap between the cache check and the await. Until the query resolves, the cache is still empty, so every concurrent request misses and fires its own database query. This causes a cache stampede, and it gets worse as traffic grows.

The fix

Cache the promise instead of 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 every concurrent request awaits the same in-flight promise. Only one database query runs per unique key, no matter how many callers are waiting.

Why it works

Promises in JavaScript execute as soon as they’re created. Awaiting the same promise multiple times returns the same resolved value without re-running the work behind it. Once it’s resolved, the cached promise behaves the same as a cached value.

Error handling

A rejected promise that stays in the cache will poison every future read. This can be mitigated by clearing the entry on failure so retries can happen:

cache.set(
  userId,
  db.getUserProfile(userId).catch((error) => {
    cache.delete(userId);
    throw error;
  }),
);

Memory is the other thing worth thinking about. A PromiseMap keeps growing until you evict from it, so beyond a small set of hot keys you’ll want a bounded map with a TTL or LRU policy.

The whole pattern is one shape change to the map. Everything else is a result of how promises already behave.