All Docs
FeaturesMaking Tax DigitalUpdated March 9, 2026

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

DatasetrevalidateCache keyInvalidated by
Billing status60 s{orgId}:billingPayment webhooks, plan changes
HMRC businesses300 s{orgId}:hmrc-businessesHMRC OAuth reconnect
Available tax years300 s{orgId}:tax-yearsAdmin updates
Organisation profile600 s{orgId}:org-profileProfile 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()

MechanismScopeSurvives request boundarySupports TTLSupports tag invalidation
React cache()Per request (deduplication only)
unstable_cacheCross-request (server cache)
In-memory (module-level)Process lifetimeManual onlyManual 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