DEV Community

Daniel Pertu
Daniel Pertu

Posted on

cache() memoises a function, not a query, so our two wrappers over one row by id fetched it twice

CogniPrep's dashboard is a React Server Component that asks a lot of questions about one user: their plan, their streak, their personal best, their recent sessions, which games they have played. Those questions are answered by lib/db/cache.ts, 219 lines holding nine functions, each wrapped in React's cache(), imported by ten files.

The file is worth reading not because per-request memoisation is clever, but because almost everything that made it faster had nothing to do with memoisation.

The dedupe that quietly did not dedupe

cache() memoises a function for the lifetime of a render pass. The subtlety is in the word function. The memo is keyed on the wrapped function reference plus its arguments, so two different wrappers that happen to run the same SQL are two separate memos. Which is exactly what we had, documented in lib/plan/plan.ts:

/**
 * Get user's full data including plan info.
 *
 * Delegates to `getCachedUser` rather than issuing its own query. Both read the
 * same row by primary key, but as separate `cache()` functions they had separate
 * cache entries, so a dashboard render that called `getUserPlan` AND
 * `getCachedUser` (via preloadDashboardData) fetched the identical row twice.
 * Routing every id-keyed read through one function makes the dedupe actually work.
 */
export const getUserData = cache(async (userId: string) => {
  const user = await getCachedUser(userId);
  if (!user) throw new Error(`User not found: ${userId}`);
Enter fullscreen mode Exit fullscreen mode

Both functions were "cached". Both were correct. Together they ran select * from users where id = $1 twice on every dashboard render, and nothing about either call site looked wrong. The fix is a delegation, not a configuration: one function owns the id-keyed read and everything else goes through it.

That funnel is now six paths deep on a single dashboard request. The layout calls getCachedUser. The page calls it directly, and again through getUserPlan, and again through getCachedUserStats, and again through the onboarding insights module, and the preload helper fires it before any of them render. Six reaches, one query, and the only reason it holds is that none of those five callers was allowed to write its own select.

A wider query, reused, beats a narrower one

This is the comment I would not have predicted writing:

export const getCachedUserStats = cache(async (userId: string) => {
  // Reuses the cached full-row read instead of issuing a second, narrower query
  // for columns that live on the row we have already fetched. The dashboard calls
  // this alongside getUserPlan, so this removes one round trip per render.
  const row = await getCachedUser(userId);
Enter fullscreen mode Exit fullscreen mode

The streak panel needs five columns. The "efficient" version of that is a five-column select. The actually faster version is to reuse the full row somebody else in this render already paid for, because the cost here is the round trip, not the width of the row. Narrowing a query is a win when it is the only query. It is a loss when it turns one round trip into two.

Half the file's wins are not caching

The three best commits in this file are all deletions.

Dropping an aggregate nobody read:

/**
 * Returns the rows only. It used to also run a `$count` over the user's entire
 * session history alongside them, to report `total` and `hasMore` - and nothing
 * has ever read either. The dashboard, its only caller, destructures
 * `{ sessions }`. That made every dashboard render pay for a full aggregate over
 * the app's largest table to compute two numbers it discarded.
 */
Enter fullscreen mode Exit fullscreen mode

Dropping a column:

/**
 * Everything except `metrics`, deliberately. `metrics` is by far the largest
 * column on the most-written table in the app, and this read exists to answer
 * "which games has this user played lately", never "how did that attempt go".
 */
Enter fullscreen mode Exit fullscreen mode

And dropping a second implementation:

export const getCachedLatestScores = cache(
  async (userId: string, gameIds: string[]) => {
    // Delegates to the repository rather than reimplementing the query. These two
    // had drifted into duplicate copies of the same unbounded scan, so a fix
    // applied to one silently missed the other.
Enter fullscreen mode Exit fullscreen mode

None of those are cache behaviour. A memo would have hidden all three: the aggregate still runs once, the jsonb blob still crosses the wire once, the unbounded scan still scans once. Memoisation makes repeated waste cheap, which is a good way to stop noticing waste.

One more, where the ORM's default is the trap:

// The filter is passed into $count so the total is scoped to this user.
// Without it Drizzle emits an unfiltered `count(*)` over the whole table.
const [logs, total] = await Promise.all([
  db.select().from(auditLogsTable).where(eq(auditLogsTable.user_id, userId))
    .orderBy(desc(auditLogsTable.created_at)).limit(limit).offset(offset),
  db.$count(auditLogsTable, eq(auditLogsTable.user_id, userId)),
]);
Enter fullscreen mode Exit fullscreen mode

A paginated list whose total is the table's total is wrong and fast-looking until the table is large, at which point it is wrong and slow.

The preload pattern only works because of the memo

Two pages start their data before they render anything:

export function preloadDashboardData(userId: string) {
  // Fire all queries in parallel - don't await
  void getCachedUser(userId);
  void getCachedUserStats(userId);
  void getCachedUserSessions(userId, { limit: 10, offset: 0 });
}
Enter fullscreen mode Exit fullscreen mode

void a cached call at the top of the page, and the components that await it later get the in-flight promise rather than a new query. It is the cheapest waterfall fix in the App Router: no props to thread, no context, no change to the components. It also only works inside the memo's scope, which brings me to the last finding.

Two route handlers import a memo that has nowhere to live

Ten files import this module and two of them are route handlers: the support tickets route pulls getCachedUser, the audit log route pulls getCachedAuditLogsWithPagination. A route handler is not a render, so there is no render pass for cache() to attach its memo to. Nothing is lost today, because each of them calls its function exactly once, and a memo you consult once is just a function call. But the name is making a promise the context cannot keep, and the next person to add a second caller inside that handler will believe the second call is free.

That is the shape of this whole file. Nine functions whose value is mostly in the queries they refuse to run, wrapped in a mechanism that only pays out when two callers in one render ask the same question, and that stops paying out the moment someone writes the same query under a different name.

See it

This surface is behind the login, so there is no URL to curl. Sign up free at cogniprep.app and load the dashboard: the plan badge, the streak, the personal best and the recent sessions list are all served by one round of these nine functions, and the row behind four of those panels is fetched once. The pricing page sets out what the free tier includes, and the practice catalogue is the public half of the same data.

Top comments (0)