typescript 142 lines · 4 tabs

TTL Cache With In-Flight Request Deduplication for Async Calls

Shared by codesnips Aug 2026
4 tabs
type Loader<T> = () => Promise<T>;

interface Entry<T> {
  value: T;
  expiresAt: number;
}

export class AsyncTtlCache<T> {
  private fresh = new Map<string, Entry<T>>();
  private inflight = new Map<string, Promise<T>>();

  constructor(private readonly ttlMs: number) {}

  get(key: string, loader: Loader<T>): Promise<T> {
    const cached = this.fresh.get(key);
    if (cached && cached.expiresAt > Date.now()) {
      return Promise.resolve(cached.value);
    }

    const pending = this.inflight.get(key);
    if (pending) {
      return pending;
    }

    const request = loader()
      .then((value) => {
        this.fresh.set(key, { value, expiresAt: Date.now() + this.ttlMs });
        this.inflight.delete(key);
        return value;
      })
      .catch((err) => {
        // Do not cache failures; let the next caller retry.
        this.inflight.delete(key);
        throw err;
      });

    this.inflight.set(key, request);
    return request;
  }

  invalidate(key: string): void {
    this.fresh.delete(key);
    this.inflight.delete(key);
  }

  clear(): void {
    this.fresh.clear();
    this.inflight.clear();
  }

  stats(): { fresh: number; inflight: number } {
    return { fresh: this.fresh.size, inflight: this.inflight.size };
  }
}
4 files · typescript Explain with highlit

This snippet builds a small async cache that solves two related problems at once: repeated expensive calls returning the same data (solved with a time-to-live cache) and the thundering-herd problem where many callers request the same key simultaneously before any result is cached (solved with in-flight deduplication).

The AsyncTtlCache tab stores two maps: fresh holds resolved values with an expiresAt timestamp, and inflight holds the pending Promise for keys currently being fetched. The core method get first checks fresh; if an entry exists and has not expired it is returned immediately. If a fetch for the same key is already running, get returns that same shared Promise instead of starting a second one — this is the deduplication step, and it is what prevents ten concurrent components from firing ten identical network requests.

When neither a fresh value nor an in-flight promise exists, get calls the supplied loader, stores the resulting promise in inflight, and attaches handlers. On success the value is written to fresh with expiresAt = now + ttlMs and the in-flight entry is cleared. Crucially, on failure the in-flight entry is also cleared inside a finally-style path so a rejected fetch does not poison the key forever; the next caller retries cleanly. Errors are intentionally not cached, since caching a transient failure is usually worse than retrying.

The invalidate and clear methods let callers evict stale data after a mutation, and stats exposes counts for debugging. A subtle detail is that expiresAt is checked lazily on read rather than with timers, avoiding a background sweep and keeping the structure simple; expired entries are simply overwritten on next access.

The useCachedResource hook tab shows real usage in React. It reads through a shared cache instance so multiple mounted components hitting the same key share one request, tracks data, error, and loading state, and guards against setting state after unmount with a cancelled flag. This pattern is ideal for autocomplete, dashboards, and any UI where the same expensive query is requested from many places at once.


Related snips

Share this code

Here's the card — post it anywhere.

TTL Cache With In-Flight Request Deduplication for Async Calls — share card
Link copied