Cache

VitNode's two caching layers - TanStack Query entries on the front end, and a Redis-backed cache inside Hono route handlers on the API.

VitNode uses a simple two-layer caching model:

  1. App cache: what the frontend keeps, so a page does not re-ask for something it already has.
  2. Redis Cache (API): Caches database queries and heavy computations inside Hono route handlers.

Caching is opt-in. Dynamic data stays fresh by default.

Caching is not the fetcher's job

fetcher() forwards the visitor's cookies, so a stored response is one visitor's data handed to another. It never caches. The two layers below are where caching belongs.


App Caching (TanStack Query)

Every read a route makes goes through TanStack Query, and that is the app cache: a route's loader warms an entry, the component reads the same one back, and a mutation invalidates exactly what it changed. One request per navigation instead of one per component.

Warm it in the loader, read it in the screen

src/routes/announcements.tsx
export const Route = createFileRoute('/announcements')({
  loader: async ({ context }) =>
    await context.queryClient.ensureQueryData(announcementsQueryOptions()),
})
announcements-screen.tsx
const { data } = useSuspenseQuery(announcementsQueryOptions())

The queryOptions object is what the two halves share - the same key, the same fetcher, the same staleTime - so the screen can never ask for something the loader did not warm. See Data loading for the isomorphic fetcher that sits behind it, and Loading states for what the screen shows while an entry is still cold.

Pick a lifetime that matches the data

staleTime is the whole configuration surface. Public data that changes rarely can sit for minutes; anything per-visitor should be short or zero:

export const announcementsQueryOptions = () =>
  queryOptions({
    queryKey: ['announcements'],
    queryFn: fetchAnnouncements,
    staleTime: 5 * 60 * 1000,
  })

Per-visitor data is per-visitor

A session, a permission set or a personal file list must not share a cache entry with anybody else. Key it by the identity it belongs to, and keep the database work behind it in the API's Redis layer instead - that is where a session read is already cached, with explicit invalidation on every mutation that changes the answer.


Invalidation

A write invalidates what it changed. invalidateQueries matches by key prefix, so invalidate the narrowest root that covers the rows a mutation could have moved:

publish-announcement.ts
const queryClient = useQueryClient()

await publishAnnouncement(id)
await queryClient.invalidateQueries({ queryKey: ['announcements'] })

A row that could be on any page under any sort means invalidating the list's root, not one page of it - the changed row may have moved.

Content Engine

Content Engine entries are tagged and expired for you. A background mutation cannot expire a frontend's cache by calling a function, so dispatchContentRevalidation posts to the origins an install opts into via content.revalidateOrigins. See Content Engine Caching.


API Caching (Redis)

Inside your Hono route handlers, c.get("cache") stores expensive query results in Redis. Keys are namespaced per plugin, so the stats:42 your plugin writes actually lives at vitnode:cache:@vitnode/example:stats:42 and cannot collide with another plugin's.

Wrap the read in remember

remember takes a key, a TTL in seconds, and the loader to run on a miss. It returns the value either way, so the call site never branches:

src/api/modules/stats/routes/overview.route.ts
handler: async c => {
  const stats = await c.get('cache').remember(
    `stats:${containerId}`,
    60 * 5,
    async () => await calculateHeavyStats(c, containerId),
  )

  return c.json(stats)
},

Only a non-null value counts as a hit, so a loader that legitimately answers null re-runs every time. Wrap it - { stats } rather than a bare nullable - if that is a miss you cannot afford.

Delete the key when the record changes

The write that changes the answer is the write that expires it. There is no TTL short enough to substitute for this:

src/api/modules/stats/routes/update.route.ts
await c.get('cache').delete(`stats:${containerId}`) 

delete also takes an array of keys. flush() drops every key belonging to the current plugin and leaves other plugins - and any unrelated data in the same Redis instance - untouched.

Verify it is actually caching

Wire up redis in buildApiConfig (see Redis setup), restart, then open System → Integrations in the AdminCP: Redis reads active when it is connected and configured but unreachable when the URL is wrong. Call the route twice and only the first call should reach the database.

Optional Redis

Redis is optional. If not configured, c.get("cache") gracefully acts as a no-op and runs the callback directly - which is one way to spell "runs your loader". Nothing you write against it needs a fallback branch.

Next