Memoize Leaf API Fan-Out

General Problem

Backends often build several UI components from the same downstream "leaf" APIs. If each UI component fetches what it needs on its own, shared APIs get called again and again. If UI components coordinate their fetching by hand, they become coupled to each other.

The fix is one small helper. Wrap each leaf API call in once for the life of a request. The first caller starts the call, and every later caller gets the same in-flight promise. Each UI component asks for exactly what it needs, and every shared call still runs only once, in parallel with the rest.

/** Memoize the promise, so every caller after the first shares the same call. */
const once = <T>(fetch: () => Promise<T>) => {
  let promise: Promise<T> | undefined;
  return () => (promise ??= fetch());
};
Concrete Example

Here, a fictional sports app builds three player stat UI components from four leaf APIs. Every UI component needs Player Profile, and two need Match History:

Choose UI Components to Render
Results
player-stats.ts
/**
 * Player stats demo, with measurement for the HTML page.
 *
 * Same pattern as ../demo-cli/player-stats.ts: a request-scoped client
 * memoizes each leaf API call, so a leaf shared by several stats is called
 * once, and every leaf call starts at the same time. getPlayerStats also
 * reports how many stats needed each leaf versus how many calls were made.
 */

// ============================================================================
// Leaf API response types + mock APIs (stand-ins for real HTTP clients)
// ============================================================================
interface PlayerProfile {
  playerId: string;
  name: string;
  rank: number;
  rating: number;
}
interface MatchHistory {
  season: string;
  played: number;
  wins: number;
  recentResults: ("W" | "L")[];
  nextOpponentId: string;
}
interface TrainingLog {
  sessionsThisWeek: number;
  hoursThisWeek: number;
  focusArea: string;
}
interface OpponentScouting {
  opponentId: string;
  name: string;
  region: string;
  rating: number;
  headToHeadWins: number;
  headToHeadLosses: number;
}

const mockCall = <T>(ms: number, value: T) =>
  new Promise<T>((resolve) => setTimeout(() => resolve(value), ms));

// latencies are high enough that the page's loading state is visible
const apis = {
  playerProfile: (playerId: string) =>
    mockCall<PlayerProfile>(800, {
      playerId,
      name: "Michael Carter",
      rank: 13,
      rating: 2_184,
    }),
  matchHistory: (playerId: string, season: string) =>
    mockCall<MatchHistory>(1_200, {
      season,
      played: 48,
      wins: 31,
      recentResults: ["W", "W", "L", "W", "W"],
      nextOpponentId: "p-907",
    }),
  trainingLog: (playerId: string) =>
    mockCall<TrainingLog>(500, {
      sessionsThisWeek: 5,
      hoursThisWeek: 11.5,
      focusArea: "Endurance",
    }),
  opponentScouting: (opponentId: string, region: string) =>
    mockCall<OpponentScouting>(700, {
      opponentId,
      name: "David Brooks",
      region,
      rating: 2_241,
      headToHeadWins: 2,
      headToHeadLosses: 3,
    }),
};
type Apis = typeof apis;
export type LeafName = keyof Apis;
export const LEAF_NAMES = Object.keys(apis) as LeafName[];

// ============================================================================
// Request-scoped leaf client: each leaf is fetched at most once per request
// ============================================================================
interface RequestParams {
  playerId: string;
  season: string;
  region: string;
  opponentId: string;
}

const PARAMS: RequestParams = {
  playerId: "p-042",
  season: "2026-S3",
  region: "NA",
  opponentId: "p-907",
};

/** Memoize the promise, so every caller after the first shares the same call. */
const once = <T>(fetch: () => Promise<T>) => {
  let promise: Promise<T> | undefined;
  return () => (promise ??= fetch());
};

function leafClient(p: RequestParams, a: Apis) {
  return {
    playerProfile: once(() => a.playerProfile(p.playerId)),
    matchHistory: once(() => a.matchHistory(p.playerId, p.season)),
    trainingLog: once(() => a.trainingLog(p.playerId)),
    opponentScouting: once(() => a.opponentScouting(p.opponentId, p.region)),
  };
}
type Leaves = ReturnType<typeof leafClient>;

// ============================================================================
// Stats: one async function each. Start every leaf you need with Promise.all,
// never `await a(); await b();`, which would fetch them one after another.
// ============================================================================
const round = (n: number, dp = 1) => Math.round(n * 10 ** dp) / 10 ** dp;

const stats = {
  performance: async (l: Leaves) => {
    const [profile, history] = await Promise.all([
      l.playerProfile(),
      l.matchHistory(),
    ]);
    return {
      name: profile.name,
      rank: profile.rank,
      season: history.season,
      winRate: round((history.wins / history.played) * 100),
      previous: history.recentResults.join(""),
    };
  },

  training: async (l: Leaves) => {
    const [profile, training] = await Promise.all([
      l.playerProfile(),
      l.trainingLog(),
    ]);
    return {
      name: profile.name,
      sessionsThisWeek: training.sessionsThisWeek,
      avgSessionHours: round(
        training.hoursThisWeek / training.sessionsThisWeek,
      ),
      focusArea: training.focusArea,
    };
  },

  matchup: async (l: Leaves) => {
    const [profile, history, opponent] = await Promise.all([
      l.playerProfile(),
      l.matchHistory(),
      l.opponentScouting(),
    ]);
    return {
      opponent: opponent.name,
      region: opponent.region,
      ratingGap: profile.rating - opponent.rating,
      headToHead: `${opponent.headToHeadWins}-${opponent.headToHeadLosses}`,
      isScheduled: history.nextOpponentId === opponent.opponentId,
    };
  },
};

export type StatName = keyof typeof stats;
export const ALL_STATS = Object.keys(stats) as StatName[];

export interface StatResult<N extends StatName> {
  data: Awaited<ReturnType<(typeof stats)[N]>>;
  /** time from request start until this stat resolved */
  ms: number;
  leaves: LeafName[];
}

// ============================================================================
// Orchestrator: run the requested stats concurrently against one shared client
// ============================================================================
export async function getPlayerStats<N extends StatName>(names: readonly N[]) {
  const start = performance.now();

  // count real API calls, which is what memoization saves
  const performed = Object.fromEntries(LEAF_NAMES.map((l) => [l, 0])) as Record<
    LeafName,
    number
  >;
  const counted = Object.fromEntries(
    LEAF_NAMES.map((l) => [
      l,
      (...args: never[]) => (
        performed[l]++,
        (apis[l] as (...a: never[]) => unknown)(...args)
      ),
    ]),
  ) as Apis;
  const shared = leafClient(PARAMS, counted);

  const results = await Promise.all(
    names.map(async (name) => {
      // each stat gets its own view of the shared client, to record which leaves it needs
      const used = new Set<LeafName>();
      const view = Object.fromEntries(
        LEAF_NAMES.map((l) => [l, () => (used.add(l), shared[l]())]),
      ) as Leaves;
      const data = await stats[name](view); // first failure rejects all
      return { data, ms: performance.now() - start, leaves: [...used] };
    }),
  );

  return {
    ms: performance.now() - start,
    stats: Object.fromEntries(names.map((n, i) => [n, results[i]])) as {
      [P in N]: StatResult<P>;
    },
    leafCalls: Object.fromEntries(
      LEAF_NAMES.map((l) => [
        l,
        {
          needed: results.filter((r) => r.leaves.includes(l)).length,
          performed: performed[l],
        },
      ]),
    ) as Record<LeafName, { needed: number; performed: number }>,
  };
}