Skip to main content

OdontoX Cache & Storage Strategy

Date: 2026-05-23 Owner: ssh Status: Plan — written after a full audit of the UI codebase

TL;DR

The audit found OdontoX already has a half-decent caching setup (TanStack Query with clinic-scoped keys, build-hash buster, decent try/catch coverage). What it lacks is discipline about where each kind of data is allowed to live. Auth tokens are in localStorage (XSS-vulnerable), PHI is leaking into odontox-patient-picker-recent, and the 24-hour query persistence is too aggressive for financial and patient data. This doc is the playbook: which kind of data lives in which tier of cache, with the rules a future engineer can apply without thinking. After the playbook, there’s a module-by-module status table and a prioritized migration plan.

The 5-tier mental model

When you have a piece of data, decide which tier it belongs to before you write the fetch. Each tier has a different lifetime, blast radius, and risk profile.
TierLifetimeWhere it livesSurvivesGood forDon’t put
T0 — Memory (component state)Tab openReact state / useStateRe-renderForm drafts being typed, transient UI flagsAnything you’d want on reload
T1 — In-memory query cacheTab open, GC’d by usageTanStack Query (no persister)Re-render, navigationPatient lists, appointments, anything that can be re-fetched cheaplyAuth tokens, user prefs
T2 — sessionStorageTab open (one tab)Browser session storageReloadPer-tab UI flags (“dashboard boot seen”), short-lived form drafts, impersonation tokensAnything you want shared across tabs
T3 — localStorageForever until clearedBrowser local storageReload, browser restartUI preferences (theme, sidebar collapsed), feature-flag bypasses, “last clinic” pointerAuth tokens, PHI, anything that gets stale fast
T4 — localStorage with TTL + versionTTL or schema bumplocalStorage with {value, expiresAt, schemaVersion} wrapperReload, browser restartPersisted query cache, reference data with known refresh cadencePHI, auth
A few hard rules that override the table:
  • Auth tokens → either httpOnly cookies (best, server-controlled) or T2 sessionStorage (acceptable, tab-scoped). Never T3. XSS bypasses any T3-stored token in seconds.
  • PHI (patient names, MRNs, phone numbers, diagnoses, clinical notes) → T1 only, never T2/T3/T4. HIPAA-style audits look for “data at rest on the device” and persistent storage qualifies even on a personal machine.
  • Clinic ID pointer (“which clinic is the user currently working in”) → T3 is fine because losing it is annoying, not dangerous. But it must be the only source of truth — never duplicate it into URL params.
  • Anything mentioned in the URL → URL is canonical, everything else is a hint. If the URL says ?clinic=A and localStorage says B, the URL wins.

TanStack Query: the staleTime/gcTime rules

The audit found gcTime: 24h is the global default — and the entire query cache is persisted to localStorage for 24 hours. That’s too aggressive for the data shapes you actually have. Pick staleTime and gcTime from this lookup table by data churn:
Data churnstaleTimegcTimerefetchOnWindowFocuspersist?
Immutable (completed appointment history, signed prescriptions, audit logs)Infinity1 hourfalseOK
Slow-churn reference (service catalog, medicine library, clinic settings, role permissions, doctor list)5 min30 minfalseOK
Medium-churn (appointments today, patient picker results, doctor schedules)30 sec5 mintrueNo
High-churn (invoice list, payment status, in-progress treatment notes, WhatsApp inbox)0 (always stale)1 mintrueNo
Real-time (current patient in chair, unread notification count, live calendar)030 sectrueNo + SSE/polling
The persister currently saves everything. The cleanest fix is to opt out of persistence per query via the meta.noPersist flag (which the dehydrateOptions filter reads):
useQuery({
  queryKey: qk.invoices.list(clinicId),
  queryFn: fetchInvoices,
  staleTime: 0,
  gcTime: 60_000,
  meta: { noPersist: true },  // never write to localStorage
});
And the persister config:
persistQueryClient({
  queryClient,
  persister,
  maxAge: 1000 * 60 * 60,        // 1h (down from 24h)
  buster: __BUILD_HASH__,
  dehydrateOptions: {
    shouldDehydrateQuery: (q) =>
      q.state.status === 'success' && !q.meta?.noPersist,
  },
});
Default gcTime stays high enough that long sessions don’t re-fetch, but meta.noPersist keeps PHI and high-churn data out of disk entirely.

The localStorage rules

The audit found ~20 keys in localStorage. Categorize each and apply the table.

Keys that should move OUT of localStorage

KeyCurrentlyMove toWhy
auth_tokenT3httpOnly cookie (preferred) or T2 sessionStorageXSS-stealable today
odontox_rt (refresh token)T3httpOnly cookieSame
impersonation_original_tokenT2 (mostly) + T3 (legacy)T2 only — delete the T3 fallbackCross-tab leakage
impersonation_user_emailT3T2 + audit logThis is PHI-adjacent — superadmin sees a real user’s email; should not survive a browser restart
odontox-patient-picker-recentT3T1 query cache scoped to the current page session OR drop entirelyNames + phone numbers are PHI
odontox-rq-${clinicId} (TanStack persister)T3 with everythingT4 with meta.noPersist filterStops PHI from being written to disk; still caches reference data

Keys that are fine where they are

KeyWhy it’s safe
odontox-active-clinic-idIdentity pointer, not PHI; required for routing on reload
odontox-user-roleCoarse role tag, not sensitive
odontox-theme-${userId}Per-user UI preference
odontox-brandingPublic-facing clinic info
odontox-access-blockedTime-limited flag
odontox_pwd_reset_cooldownRate-limit timestamp
auth_error_type, last_session_idError context for support

A wrapper to use going forward

Don’t call localStorage.setItem directly anywhere. Wrap once:
// ui/src/lib/storage.ts
interface StoredValue<T> {
  v: 1;                  // schema version — bump to invalidate old data
  exp?: number;          // optional expiry epoch ms
  data: T;
}

export function setStored<T>(key: string, data: T, ttlMs?: number) {
  try {
    const payload: StoredValue<T> = { v: 1, data };
    if (ttlMs) payload.exp = Date.now() + ttlMs;
    localStorage.setItem(key, JSON.stringify(payload));
  } catch { /* Safari private mode, quota — silent */ }
}

export function getStored<T>(key: string): T | null {
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
    const parsed = JSON.parse(raw) as StoredValue<T>;
    if (parsed.v !== 1) { localStorage.removeItem(key); return null; }
    if (parsed.exp && parsed.exp < Date.now()) { localStorage.removeItem(key); return null; }
    return parsed.data;
  } catch { return null; }
}
Three properties this gets you for free:
  1. Schema versioning — bumping v invalidates every prior value, no migration needed.
  2. TTL — caller decides expiry per key; no separate “what’s stale” tracking.
  3. Crash-safety — every read is wrapped in try/catch; corrupted JSON returns null instead of throwing.

Multi-tab coordination (currently missing)

When a user opens two tabs and switches clinic in tab A, tab B keeps showing clinic A data until they manually refresh. The audit confirmed there’s no BroadcastChannel sync. Add this once in App.tsx:
useEffect(() => {
  const bc = new BroadcastChannel('odontox-sync');
  bc.onmessage = (e) => {
    if (e.data?.type === 'clinic-switched' || e.data?.type === 'logout') {
      queryClient.clear();
      window.location.reload();
    }
  };
  return () => bc.close();
}, []);
And on the actions that should broadcast:
// in setActiveClinicId()
new BroadcastChannel('odontox-sync').postMessage({ type: 'clinic-switched' });

// in logout flow
new BroadcastChannel('odontox-sync').postMessage({ type: 'logout' });
Two-line cost; eliminates the entire class of “I logged out in one tab but the other one still works”.

URL state vs storage state

Rule: URL is canonical for anything a user might bookmark or share. Storage is for things the user shouldn’t have to specify (theme, last view). Current OdontoX usage is mostly clean (the audit found no double-source-of-truth on entity IDs). One thing to nail down going forward:
  • View toggles (?view=appointments, ?tab=billing) → URL, not localStorage. A reception staffer who refreshes mid-shift should land where they were.
  • Search queries (e.g., patient search box) → URL (?q=...) so back/forward works.
  • Active clinic ID → localStorage (T3) because it’s not in the URL today; if you ever add clinic.odontox.io/... subdomain routing, the subdomain becomes canonical.

Module-by-module verdict and action items

Status from the audit, with the explicit “what to do”:
ModuleStatusWhat to do
AuthAT RISKMove tokens to httpOnly cookies (server change + frontend fetchWithAuth reads from /me). Drop T3 fallback for impersonation token.
Dashboard (admin)SAFENo action
Dashboard (reception)SAFENo action — but when we build the “Now Rail” + Quick Appointments, those should be T1 only
Dashboard (doctor)AT RISKReduce treatment-plan gcTime to 5min, add meta.noPersist
AppointmentsAT RISKmeta.noPersist on calendar queries; keep 5min staleTime but let it GC out of disk
PatientsAT RISKDrop odontox-patient-picker-recent entirely (or move to T1 in-memory queryClient); meta.noPersist on patient lists
PrescriptionsSAFEAlready migrated to search-as-you-type; no action
Treatment PlansAT RISKmeta.noPersist; the plan IS the medical record, no caching to disk
Dental ChartSAFENo action
Service CatalogSAFEAlready correctly tuned (15min/30min). Keep persistence on — this is the canonical “OK to persist” example
FinanceAT RISKmeta.noPersist on invoices, quotations, receipts. Money data MUST be fresh; show a tiny “last refreshed Xs ago” timestamp
InventorySAFENo action
LabSAFENo action — admin-curated, low churn
WhatsApp v2SAFENo action
Settings / StaffSAFENo action — already low churn
Superadmin / TenantsAT RISKImpersonation token cleanup (see Auth row). Tenant data: meta.noPersist

Migration plan — three batches, lowest risk first

Batch 1 — Low-risk wins (half-day, no schema/server changes)

  1. Write ui/src/lib/storage.ts wrapper (the snippet above) and migrate the safe keys to use it. No behavior change, but every read gets schema-version + try/catch for free.
  2. Add meta.noPersist flag support to the QueryClient persister (the dehydrateOptions.shouldDehydrateQuery filter).
  3. Tag finance, treatment-plan, and patient-list queries with meta: { noPersist: true }. Drop persister maxAge from 24h to 1h.
  4. Drop odontox-patient-picker-recent writes. Replace the “recent patients” feature with a server endpoint (e.g., GET /patients/recent?userId=...) cached in T1 only — that data lives on the server anyway and avoids PHI-at-rest entirely.

Batch 2 — Multi-tab + impersonation cleanup (half-day)

  1. Add the BroadcastChannel sync in App.tsx. Wire clinic-switched and logout broadcasts.
  2. Move impersonation_original_token and impersonation_user_email to sessionStorage-only. Delete the localStorage fallbacks.
  3. Make logout proactively bc.postMessage({type:'logout'}) so every tab knows.

Batch 3 — Auth tokens to httpOnly cookies (full day, needs server work)

  1. Server: switch sign-in to set httpOnly + Secure + SameSite=Lax cookie. Add /api/v1/protected/me to read the current user from the cookie.
  2. Frontend: fetchWithAuth stops reading from localStorage; trust the browser to attach the cookie. Drop auth_token and odontox_rt writes.
  3. Logout: server clears the cookie via Set-Cookie: ...; Max-Age=0. Frontend just calls the endpoint.
  4. Migration window: support both for one release, then remove localStorage reads.

Out of scope (later, if real signal exists)

  • IndexedDB — overkill for current data shapes; only consider if we go offline-first.
  • Service worker — adds a whole new failure mode (stale shell). Skip until there’s a real offline use case.
  • Server-pushed cache invalidation (SSE cache-invalidate events) — nice-to-have but not blocking.

Quick “does this break things?” checklist for future caching changes

Before adding any new setItem / setStored / useQuery with a custom staleTime, run through this:
  • If the data has a patient name, MRN, phone, or clinical detail → it does NOT go to T3/T4.
  • If the data is an auth token → it does NOT go to T3.
  • If the data is shown in the URL → URL is canonical, storage is a hint at best.
  • If the data is shared across tabs (clinic switch, logout) → BroadcastChannel notifies.
  • If the data has a hard freshness need (money, schedule, in-progress treatment) → meta.noPersist AND short staleTime.
  • If the data is reference/admin-curated → persist freely, long staleTime is fine.
  • If you’re using JSON.parse → it’s wrapped in try/catch.
  • If you’re storing a value that might be missing later → null-check before assuming shape.
If you can’t tick all the boxes that apply, the data doesn’t belong in cache yet. Don’t ship it.

What you don’t need to do

The audit cleared these — don’t waste time on them:
  • The TanStack Query setup is sound; the issue is what gets persisted, not the library choice.
  • The clinic-scoped query keys are working; no need to redesign the key shape.
  • The build-hash buster is doing its job; deploys do invalidate stale persisted data.
  • The custom hooks (useLocalStorage, useRolePersistedState) already have try/catch — keep using them.
  • No service worker means no offline staleness — that’s a feature, leave it.