./ahmedhashim

Caching with PromiseMaps

Most caches are written as Map<Key, Value>. For anything that gets hit concurrently, the more useful shape is Map<Key, Promise<Value>>. That one change is enough to avoid a whole class of stampede bugs.

The problem

Picture a social network where popular user profiles get fetched hundreds of times per second. The naive cache looks 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. That’s a cache stampede, and it gets worse the more traffic you have.

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 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. Clear the entry on failure so retries can happen:

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

Memory’s 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 falls out of how promises already behave.