Performance Deep-Dive: Why We're Caching Stable Data
Performance Deep-Dive: Why We're Caching Stable Data
Release: v0.1.179 · Control: PERF-18 · Category:
perf_data_patterns
The Problem
Not every piece of data in the platform changes frequently. Billing plans are updated a handful of times a year. Compliance rule definitions are tied to specific legislation. Help articles are edited occasionally. Org settings and subscription status might change once in a billing cycle.
Despite this, our audit found that the platform currently has no server-side caching layer for any of these data types. Every tRPC call — regardless of how static the underlying data is — fires a fresh database query or re-executes rule logic from scratch.
This is a correctness-safe approach, but it leaves significant performance on the table, especially on high-frequency code paths.
What the Audit Found
Hot path: orgProcedure
Every authenticated, organisation-scoped API call passes through orgProcedure. As part of that middleware, the platform fetches the current subscription status for the organisation. Because orgProcedure wraps nearly all tRPC routes, this single database query is one of the most frequently executed queries in the entire system — and it runs fresh every single time.
Redundant reads for org-agnostic data
Billing plans are shared across all organisations and almost never change. Today they are re-read from the database on every request that needs them. There is no shared result, no in-flight deduplication, and no TTL-based cache.
Already-correct patterns in lib/compliance/
It's worth noting what is not a problem: compliance rule definitions and help articles are already implemented as static in-memory arrays inside lib/compliance/. These are loaded once at server startup and never re-fetched. This is the right pattern — the audit confirms these should stay as-is.
The Fix
Two complementary caching tools cover the different scenarios:
1. unstable_cache — for org-agnostic, database-sourced data
Next.js's unstable_cache wraps an async function with a persistent, TTL-based server-side cache. It's the right tool for data that is shared across all users and changes infrequently.
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
import { plans } from '@/lib/db/schema';
export const getPlans = unstable_cache(
async () => db.select().from(plans),
['plans'],
{ revalidate: 3600 } // re-fetch at most once per hour
);
With this in place, billing plan data is fetched once, cached at the server level, and automatically invalidated after one hour. All requests in that window share the same result with zero additional database load.
2. React.cache() — for per-org data within a single request
React's built-in cache() function deduplicates calls within a single server render or request lifecycle. It doesn't persist across requests, making it safe for per-organisation data like settings and subscription status — data that is stable within a request but may differ between organisations.
import { cache } from 'react';
import { db } from '@/lib/db';
import { subscriptions } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
export const getSubscriptionStatus = cache(async (orgId: string) => {
return db
.select()
.from(subscriptions)
.where(eq(subscriptions.orgId, orgId));
});
If orgProcedure calls getSubscriptionStatus and a downstream resolver calls it again for the same orgId in the same request, the second call returns the memoised result instantly — no second database round-trip.
For subscription status specifically, combining React.cache() with a 60-second unstable_cache TTL would reduce database load further by sharing results across concurrent requests for the same organisation.
Recommended Cache Strategy by Data Type
| Data | Changes how often? | Recommended approach | TTL |
|---|---|---|---|
| Billing plans | Rarely (manual update) | unstable_cache | 1 hour |
| Compliance rule definitions | On legislation change | Already static in-memory ✓ | — |
| Help articles | Occasionally | Already static in-memory ✓ | — |
| Org settings | Rarely | React.cache() | Per request |
| Subscription status | Monthly (billing cycle) | React.cache() + unstable_cache | 60 seconds |
Why This Matters
The platform is designed to handle deposit checks, compliance assessments, and dispute resolution — workflows where multiple tRPC calls fan out in parallel. Each of those calls currently pays the full cost of fetching subscription status and plan data independently. As usage grows, that overhead compounds.
Introducing even a 60-second cache on orgProcedure's subscription check would turn what is currently the platform's single most-repeated database query into an occasional background refresh.
What Stays the Same
- No user-visible behaviour changes. Caching is transparent.
- Compliance rule logic is unaffected — it's already static.
- Cache invalidation on plan or subscription updates will need to pair with
revalidateTag()to ensure consistency when data does change.