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:
- App cache: what the frontend keeps, so a page does not re-ask for something it already has.
- 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
export const Route = createFileRoute('/announcements')({
loader: async ({ context }) =>
await context.queryClient.ensureQueryData(announcementsQueryOptions()),
})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:
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:
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:
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
Data loading
Query options, route loaders and the invalidation that keeps the app cache honest.
Redis
Configure the shared cache that also backs the rate limiter and realtime pub/sub.
Fetcher
The typed call into your Hono API - and why it deliberately caches nothing.
Content Engine caching
Tagged public reads, revalidation origins and what a background mutation can expire.