Engineering: PERF-18 — Server-Side Caching Strategy for Reference Data
PERF-18: Server-Side Caching Strategy for Reference Data
Type: Performance finding · Category:
perf_data_patterns· Release: v1.0.338
Background
As the platform scales, repeated Postgres round-trips for data that rarely changes become an unnecessary source of latency and database load. PERF-18 tracks the absence of any server-side caching layer for a set of reference datasets that are read on virtually every tRPC request.
Affected Data
The following datasets are currently fetched live from Postgres on every request:
- HMRC businesses list — changes only when a user re-authenticates with HMRC via OAuth
- Billing status — changes at most once per day (on payment events)
- Available tax years — changes only when HMRC opens a new submission window
- Organisation profile — changes only on explicit user action
None of these datasets benefit from always being live. Serving data that is up to 1–10 minutes stale is acceptable for all four, and doing so significantly reduces database pressure.
Recommended Implementation
1. Wrap tRPC procedures with unstable_cache
Next.js provides unstable_cache for caching the result of async functions at the server layer. Wrap each low-volatility procedure as follows:
import { unstable_cache } from 'next/cache';
// Billing status — revalidate every 60 seconds
const getCachedBillingStatus = (orgId: string) =>
unstable_cache(
() => db.query.billingStatus.findFirst({ where: eq(billingStatus.orgId, orgId) }),
[orgId, 'billing'],
{ tags: [`${orgId}:billing`], revalidate: 60 }
)();
// HMRC businesses — revalidate every 5 minutes
const getCachedHmrcBusinesses = (orgId: string) =>
unstable_cache(
() => db.query.hmrcBusinesses.findMany({ where: eq(hmrcBusinesses.orgId, orgId) }),
[orgId, 'hmrc-businesses'],
{ tags: [`${orgId}:hmrc-businesses`], revalidate: 300 }
)();
// Available tax years — revalidate every 5 minutes
const getCachedTaxYears = (orgId: string) =>
unstable_cache(
() => db.query.taxYears.findMany({ where: eq(taxYears.orgId, orgId) }),
[orgId, 'tax-years'],
{ tags: [`${orgId}:tax-years`], revalidate: 300 }
)();
// Organisation profile — revalidate every 10 minutes
const getCachedOrgProfile = (orgId: string) =>
unstable_cache(
() => db.query.organisations.findFirst({ where: eq(organisations.id, orgId) }),
[orgId, 'org-profile'],
{ tags: [`${orgId}:org-profile`], revalidate: 600 }
)();
2. Revalidate on mutation
Tag-based invalidation ensures cached data is immediately evicted when a mutation makes it stale. Call revalidateTag at the end of any tRPC mutation that modifies cached data:
import { revalidateTag } from 'next/cache';
// After updating billing (e.g. on Stripe webhook)
revalidateTag(`${orgId}:billing`);
// After user reconnects HMRC OAuth
revalidateTag(`${orgId}:hmrc-businesses`);
// After user updates their org profile
revalidateTag(`${orgId}:org-profile`);
This pattern gives you time-based background revalidation as a safety net, with immediate on-demand invalidation for known mutations.
3. Revalidation TTL summary
| Dataset | revalidate | Cache key | Invalidated by |
|---|---|---|---|
| Billing status | 60 s | {orgId}:billing | Payment webhooks, plan changes |
| HMRC businesses | 300 s | {orgId}:hmrc-businesses | HMRC OAuth reconnect |
| Available tax years | 300 s | {orgId}:tax-years | Admin updates |
| Organisation profile | 600 s | {orgId}:org-profile | Profile update mutations |
Multi-Tenancy Considerations
All cache keys and tags are scoped to orgId. This is critical in a multi-tenant architecture: a cache hit for one organisation must never bleed into another organisation's data. The pattern [orgId, 'billing'] as the cache key array ensures Next.js stores a distinct cache entry per organisation.
Why unstable_cache and Not React cache()
| Mechanism | Scope | Survives request boundary | Supports TTL | Supports tag invalidation |
|---|---|---|---|---|
React cache() | Per request (deduplication only) | ❌ | ❌ | ❌ |
unstable_cache | Cross-request (server cache) | ✅ | ✅ | ✅ |
| In-memory (module-level) | Process lifetime | ✅ | Manual only | Manual only |
React cache() provides request-level deduplication — useful, but it does not persist across requests and therefore does not reduce database load. unstable_cache persists the result across requests until the TTL expires or a tag is explicitly invalidated, making it the correct tool for this use case.
In-memory caching at the module level is discouraged in serverless/edge deployments because process restarts invalidate the cache unpredictably and multiple instances cannot share state.
Expected Outcome
- Reduced Postgres query volume for the four reference datasets
- Lower p50 and p99 latency on tRPC procedures that currently always hit the database
- Predictable cache invalidation via per-organisation tags, with TTL-based fallback
- No correctness risk — the highest-frequency dataset (billing status) has a maximum 60-second staleness window, which is well within acceptable tolerance