OdontoX Cache & Storage Strategy
Date: 2026-05-23 Owner: ssh Status: Plan — written after a full audit of the UI codebaseTL;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 inlocalStorage (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.| Tier | Lifetime | Where it lives | Survives | Good for | Don’t put |
|---|---|---|---|---|---|
| T0 — Memory (component state) | Tab open | React state / useState | Re-render | Form drafts being typed, transient UI flags | Anything you’d want on reload |
| T1 — In-memory query cache | Tab open, GC’d by usage | TanStack Query (no persister) | Re-render, navigation | Patient lists, appointments, anything that can be re-fetched cheaply | Auth tokens, user prefs |
T2 — sessionStorage | Tab open (one tab) | Browser session storage | Reload | Per-tab UI flags (“dashboard boot seen”), short-lived form drafts, impersonation tokens | Anything you want shared across tabs |
T3 — localStorage | Forever until cleared | Browser local storage | Reload, browser restart | UI preferences (theme, sidebar collapsed), feature-flag bypasses, “last clinic” pointer | Auth tokens, PHI, anything that gets stale fast |
T4 — localStorage with TTL + version | TTL or schema bump | localStorage with {value, expiresAt, schemaVersion} wrapper | Reload, browser restart | Persisted query cache, reference data with known refresh cadence | PHI, auth |
- 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=Aand localStorage saysB, the URL wins.
TanStack Query: the staleTime/gcTime rules
The audit foundgcTime: 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 churn | staleTime | gcTime | refetchOnWindowFocus | persist? |
|---|---|---|---|---|
| Immutable (completed appointment history, signed prescriptions, audit logs) | Infinity | 1 hour | false | OK |
| Slow-churn reference (service catalog, medicine library, clinic settings, role permissions, doctor list) | 5 min | 30 min | false | OK |
| Medium-churn (appointments today, patient picker results, doctor schedules) | 30 sec | 5 min | true | No |
| High-churn (invoice list, payment status, in-progress treatment notes, WhatsApp inbox) | 0 (always stale) | 1 min | true | No |
| Real-time (current patient in chair, unread notification count, live calendar) | 0 | 30 sec | true | No + SSE/polling |
meta.noPersist flag (which the dehydrateOptions filter reads):
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
| Key | Currently | Move to | Why |
|---|---|---|---|
auth_token | T3 | httpOnly cookie (preferred) or T2 sessionStorage | XSS-stealable today |
odontox_rt (refresh token) | T3 | httpOnly cookie | Same |
impersonation_original_token | T2 (mostly) + T3 (legacy) | T2 only — delete the T3 fallback | Cross-tab leakage |
impersonation_user_email | T3 | T2 + audit log | This is PHI-adjacent — superadmin sees a real user’s email; should not survive a browser restart |
odontox-patient-picker-recent | T3 | T1 query cache scoped to the current page session OR drop entirely | Names + phone numbers are PHI |
odontox-rq-${clinicId} (TanStack persister) | T3 with everything | T4 with meta.noPersist filter | Stops PHI from being written to disk; still caches reference data |
Keys that are fine where they are
| Key | Why it’s safe |
|---|---|
odontox-active-clinic-id | Identity pointer, not PHI; required for routing on reload |
odontox-user-role | Coarse role tag, not sensitive |
odontox-theme-${userId} | Per-user UI preference |
odontox-branding | Public-facing clinic info |
odontox-access-blocked | Time-limited flag |
odontox_pwd_reset_cooldown | Rate-limit timestamp |
auth_error_type, last_session_id | Error context for support |
A wrapper to use going forward
Don’t calllocalStorage.setItem directly anywhere. Wrap once:
- Schema versioning — bumping
vinvalidates every prior value, no migration needed. - TTL — caller decides expiry per key; no separate “what’s stale” tracking.
- Crash-safety — every read is wrapped in try/catch; corrupted JSON returns
nullinstead 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 inApp.tsx:
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”:| Module | Status | What to do |
|---|---|---|
| Auth | AT RISK | Move tokens to httpOnly cookies (server change + frontend fetchWithAuth reads from /me). Drop T3 fallback for impersonation token. |
| Dashboard (admin) | SAFE | No action |
| Dashboard (reception) | SAFE | No action — but when we build the “Now Rail” + Quick Appointments, those should be T1 only |
| Dashboard (doctor) | AT RISK | Reduce treatment-plan gcTime to 5min, add meta.noPersist |
| Appointments | AT RISK | meta.noPersist on calendar queries; keep 5min staleTime but let it GC out of disk |
| Patients | AT RISK | Drop odontox-patient-picker-recent entirely (or move to T1 in-memory queryClient); meta.noPersist on patient lists |
| Prescriptions | SAFE | Already migrated to search-as-you-type; no action |
| Treatment Plans | AT RISK | meta.noPersist; the plan IS the medical record, no caching to disk |
| Dental Chart | SAFE | No action |
| Service Catalog | SAFE | Already correctly tuned (15min/30min). Keep persistence on — this is the canonical “OK to persist” example |
| Finance | AT RISK | meta.noPersist on invoices, quotations, receipts. Money data MUST be fresh; show a tiny “last refreshed Xs ago” timestamp |
| Inventory | SAFE | No action |
| Lab | SAFE | No action — admin-curated, low churn |
| WhatsApp v2 | SAFE | No action |
| Settings / Staff | SAFE | No action — already low churn |
| Superadmin / Tenants | AT RISK | Impersonation 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)
- Write
ui/src/lib/storage.tswrapper (the snippet above) and migrate the safe keys to use it. No behavior change, but every read gets schema-version + try/catch for free. - Add
meta.noPersistflag support to the QueryClient persister (thedehydrateOptions.shouldDehydrateQueryfilter). - Tag finance, treatment-plan, and patient-list queries with
meta: { noPersist: true }. Drop persistermaxAgefrom 24h to 1h. - Drop
odontox-patient-picker-recentwrites. 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)
- Add the BroadcastChannel sync in
App.tsx. Wireclinic-switchedandlogoutbroadcasts. - Move
impersonation_original_tokenandimpersonation_user_emailto sessionStorage-only. Delete the localStorage fallbacks. - Make logout proactively
bc.postMessage({type:'logout'})so every tab knows.
Batch 3 — Auth tokens to httpOnly cookies (full day, needs server work)
- Server: switch sign-in to set httpOnly + Secure + SameSite=Lax cookie. Add
/api/v1/protected/meto read the current user from the cookie. - Frontend:
fetchWithAuthstops reading from localStorage; trust the browser to attach the cookie. Dropauth_tokenandodontox_rtwrites. - Logout: server clears the cookie via
Set-Cookie: ...; Max-Age=0. Frontend just calls the endpoint. - 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-invalidateevents) — nice-to-have but not blocking.
Quick “does this break things?” checklist for future caching changes
Before adding any newsetItem / 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.noPersistAND 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.
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.

