Skip to main content
Caching in Voyant is a performance optimization, never part of the correctness model. That single principle shapes everything else: what is safe to cache, which backend a deployment uses, and which public routes opt in. If stale or lost cache state would break correctness, the data does not belong in the cache. This page covers the cache architecture, the public route cache policy, the cache headers and invalidation model, and how to use all of it for storefront performance.

Cache is not coordination

The first rule is a boundary, not a feature. Voyant does not use cache as the source of truth for locks, leader election, strongly consistent counters, transactional state, or concurrency control. Those belong in the database or a runtime coordination layer. A practical corollary: every cache usage must assume misses happen, entries expire, entries get invalidated, and backends differ in propagation timing. The code path behind the cache must still be correct when the cache is empty.
A cache miss should be a performance event, not a product bug. The live database path has to remain correct on every miss, on every route, on every backend.

Backends through one small contract

Voyant exposes a narrow shared cache contract so the same code can run against different backends:
await cache.get(key)
await cache.set(key, value, ttl)
await cache.delete(key)
// optional batched helpers such as getMany(...) when genuinely useful
The contract stays small enough that both KV and Redis can implement it honestly. The deployment chooses the backend; portable code targets the contract.
BackendGood forDefault in
Cloudflare KVRead-heavy, staleness-tolerant reference caching: storefront settings, market and config lookups, localization bundles, cacheable query results.Cloudflare-first templates.
RedisFresher invalidation visibility, richer cache patterns, stronger write/read freshness expectations, Node and container runtimes.Node and container deployments that need its semantics.
In-memoryLocal development only.Local and dev.
Do not promise Redis semantics through a KV adapter. If a feature needs immediate invalidation visibility, atomic operations, distributed locks, or rich data structures, it is no longer a pure cache concern and must require a backend with those semantics explicitly. KV is for read-heavy, best-effort caching where eventual consistency is acceptable; it is the wrong fit for hot write-heavy keys or invalidation-sensitive state.
Backend selection is a deployment concern behind the shared interface, not a reason to fork the framework. Cloudflare-first templates default to KV because it matches the deployment model, stays operationally simple, and is cheap for read-heavy reference caching.

What is cacheable

Cache read-heavy derived data, never primary mutable business state. Good candidates:
  • Public settings and deploy configuration.
  • Catalog-derived reference payloads.
  • Expensive but repeatable read models.
  • Derived storefront and public query results.
  • Locale-aware rendered fragments.

The public route cache policy

The Worker in front of /v1/public/* has a response cache, and it is conservative by design. It only stores a GET /v1/public/* response when both conditions hold:
  1. The route explicitly emits Cache-Control: public, s-maxage=....
  2. The response has no Set-Cookie.
It uses the Cache API when available and falls back to the env.CACHE KV binding in Cloudflare runtimes where caches.default is unavailable. Nothing is cached by accident: a route is cached only if it opts in with the right header and carries no cookie.

Policy classes

Every public route is classified into one cache policy before it is built.
Policy classMeaning
shared-response-cacheNon-personalized, stale-tolerant public GET. Emits Cache-Control: public, s-maxage=..., stale-while-revalidate=....
kv-read-modelPublic read backed by a KV or document read model. Still emits shared response-cache headers unless it varies by request headers without a matching Vary.
private-no-storeBearer-like IDs, customer, session, payment, proposal, contract instance, signature, or PII, or any response with Set-Cookie. Emits Cache-Control: private, no-store.
live-by-correctnessVolatile price quote, hold, booking, payment mutation, eligibility, or write flow where stale data changes correctness. Never cached.
index-backedSearch and index reads. GET searches can use shared response cache when non-personalized; POST searches are not cached because the cache key is URL-only.

How common routes are classified

  • Public product browse and detail, categories, tags, destinations: shared-response-cache and kv-read-model.
  • Storefront departure browse and detail, itineraries, availability snapshots, offer reads: shared-response-cache. Checkout and customer mutations stay live.
  • Cruise, charter, and commerce public GETs, transport requirements, published legal reference content: shared-response-cache. Their quote POST routes are live-by-correctness.
  • Operator public profile, public settings, payment-link config: shared-response-cache. Payment sessions stay private.
  • Proposals, finance customer portal, document delivery, contract instances and signatures, payment sessions: private-no-store.
  • Catalog POST search: index-backed, and not response-cached today because the request body is outside the URL cache key.
Storefront settings are deliberately not shared-cached when deployments resolve variants from request headers such as x-storefront. Likewise, catalog sourced content is kv-read-model rather than shared response cache while locale can fall back to Accept-Language. The rule below explains why.

Cache headers and invalidation

The header you emit is the contract that decides caching. Three headers matter.
// Non-personalized, stale-tolerant public GET.
c.header("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600")

// Personalized or bearer-like public route returning sensitive state.
c.header("Cache-Control", "private, no-store")

// Cacheable but varies by a normalized request header.
c.header("Vary", "Accept-Language")
  • s-maxage is what the shared response cache reads. Without it, a public GET is not cached at all.
  • stale-while-revalidate lets the edge serve a slightly stale response while it refreshes in the background, which is ideal for browse and reference data.
  • private, no-store is the explicit opt-out for anything customer, session, or payment scoped.
Invalidation in Voyant is TTL-first, not push-based. The model assumes eventual consistency: you set a sensible s-maxage, tolerate staleness for that window, and let entries expire. This is why only stale-tolerant data is shared-cached in the first place. There is no general cache-purge guarantee on the public response cache, so do not cache something that must change the instant the underlying data changes; classify it live-by-correctness or private-no-store instead.
Do not cache a route that varies by request headers unless the route also emits the correct Vary header or normalizes the variant into the URL or cache key. A response cached without Vary will serve the wrong variant to the wrong caller. Require locale in the URL or cache key before opting locale-varying content into shared response caching.
The mechanical guardrail for all of this is pnpm verify:public-cache-policy, which checks that public routes match their declared cache class.

Guidance for storefront performance

The storefront is the customer-facing booking experience, and it is read-heavy: discovery, product detail, departures, itineraries, availability snapshots. That profile is exactly what shared response caching is built for.
1

Cache the browse and detail surface

Product, departure, itinerary, and reference GETs are shared-response-cache. Emit public, s-maxage=... with stale-while-revalidate so the edge serves fast and refreshes in the background.
2

Keep checkout and quotes live

Price quotes, holds, bookings, payments, and eligibility are live-by-correctness. They re-verify against the live database on every request. Browse data can be stale; the money path cannot.
3

Normalize variants into the cache key

If content varies by locale, market, or storefront variant, put the variant in the URL or a normalized cache key (or emit Vary). Never let a header silently fork the response under one cached key.
4

Read models through KV where it helps

Product detail and similar reads can read through env.CACHE as a kv-read-model while still emitting shared response-cache headers, layering an application cache under the response cache.
5

Stay correct on a cold cache

Every cached route must work when KV is empty and the response cache misses. Treat the cache as acceleration over a correct live path, not as the path itself.
When you author a new public route, choose its cache policy class before you implement it. The class decides the header you emit, whether a cookie is allowed, and whether the response cache will ever store it. Deciding afterward is how a personalized response ends up shared-cached.

Next steps

API routes

The public surface and how routes emit their cache headers.

Storefront

The customer-facing surface that benefits most from caching.

Services

The live path behind every cache, correct on every miss.

Architecture

The per-organization runtime where the cache backend lives.