# Cache invalidation *Layer: Backend → Frontend · server-driven query-cache invalidation* Generated mutations tell the frontend what to drop — the backend does, not the FE. Every write handler stamps an `X-Invalidate-Queries` response header listing the TanStack queryKeys to evict; a FE response interceptor turns each into a `queryClient.invalidateQueries` call. Generated CRUD wires both ends automatically, so the common case needs no code. ## How it works The header is the whole contract. The backend collects queryKeys through a per-request dependency and serializes them onto the response; the frontend reads that header and invalidates. Because the FE query hooks and the BE header both root their keys at the resource's **cache key**, a single string lines the two sides up — no shared client, no manual key bookkeeping. - **BE** — `fsh_lib.invalidation.QueryInvalidations` is a per-request collector injected via `Depends(QueryInvalidations)`. Each `add_all` / `add_one` re-serializes the running list onto the header eagerly, so a handler can return at any point with no flush ritual. Generated create / update / delete / action handlers thread the dep in (`invalidations_wiring` in `codegen-be/operations/renderers.py`) and emit `invalidations.add_all()`. - **The cache key** is `resolve_cache_key(resource)` — the plural snake of the model (`Category` → `"categories"`), or a `cache_key` override. The same string is stamped as each route's `x-cache-key` openapi extension, so the FE and the invalidation header agree by construction. - **FE** — `installInvalidations(queryClient)` (wired once in `main.tsx`) installs a response interceptor on the generated client that reads the header and invalidates each queryKey. Generated query hooks (`_generated/queries/`, from the `cache-key-queries` openapi-ts plugin) root their queryKeys at `[cache_key, ...path, body?]`, so `add_all("categories")` emits `["categories"]`, whose prefix match blows the list **and** every `["categories", id]` detail in one shot. - **CORS** — `X-Invalidate-Queries` is a custom *response* header. Cross-origin, browsers hide it from JS unless it is in `expose_headers`, so the `be_root` `main.py` template lists it; without it the interceptor reads `null` and no refetch ever fires. `allow_headers=["*"]` does **not** cover this (it governs the *request* side only). Two scopes: - **`add_all(cache_key)`** → `[cache_key]` — blow everything for the resource (list caches + every per-id detail). Right for create / delete / anything that moves rows in or out of a list. This is what generated CRUD emits. - **`add_one(cache_key, id)`** → `[cache_key, id]` — blow just that detail cache, leaving lists alone. Useful when one row's representation changed but no list filters on the changed field. ## The config you write **Nothing** for generated CRUD — the dependency, the header, and the FE interceptor are all emitted. You touch invalidation only in hand-written handlers, which opt in by declaring the dependency: ```python from typing import Annotated from fastapi import APIRouter, Depends from fsh_lib.invalidation import QueryInvalidations router = APIRouter(prefix="/projects") @router.delete("/{id}") async def delete_project( id: int, invalidations: Annotated[QueryInvalidations, Depends(QueryInvalidations)], ) -> None: # ...delete the row... invalidations.add_all("projects") # list + every detail # or, narrower: invalidations.add_one("projects", id) ``` The same dep also extends the auto-generated set — a post-hook on one resource can `add_all` a *sibling's* cache key when a write cascades across resources. ## The frontend codegen emits **Scaffolded, safe to edit** — `fe/src/api/invalidation.ts`: the response interceptor, written once at bootstrap. It reads the header and invalidates; edit freely (e.g. to log or filter keys): ```typescript import { client } from "../_generated/client.gen"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; const HEADER_NAME = "X-Invalidate-Queries"; export function installInvalidations(queryClient: QueryClient): void { client.interceptors.response.use((response) => { const raw = response.headers.get(HEADER_NAME); if (raw === null) return response; (JSON.parse(raw) as QueryKey[]).forEach((queryKey) => queryClient.invalidateQueries({ queryKey }), ); return response; }); } ``` **You write** — `fe/src/main.tsx`: wire it once, right after constructing the query client: ```typescript import { installInvalidations } from "./api/invalidation"; installInvalidations(queryClient); ``` Most CRUD hides a missing header behind a navigation (a form's `window.history.back()` remounts the list, which refetches on its own). The mechanism only becomes load-bearing where the mutation and the stale data share a page — saved views on a same-page tab strip, or [comments](commenting.md) posted inline — which is exactly when a dropped `expose_headers` bites. See also: [resources](resources.md), [table configuration](table-configuration.md), [commenting](commenting.md), [auditing](auditing.md).