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 (KVStore) so the same code can run against different backends:
Cache helper (createCacheFromEnv(env)) wraps a KVStore with JSON serialization and a friendlier get / set(key, value, ttlSeconds) / del surface. The deployment chooses the backend; portable code targets the contract.
Cloudflare KV is the cache backend. (Upstash Redis was previously an option but has been removed; the
getRedis* helpers are deprecated no-ops.) If a future feature genuinely needs immediate invalidation visibility, atomic operations, or distributed locks, that is no longer a pure cache concern and must require a backend with those semantics explicitly, behind the same interface.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:
- The route explicitly emits
Cache-Control: public, s-maxage=.... - The response has no
Set-Cookie.
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.How common routes are classified
- Public product browse and detail, categories, tags, destinations:
shared-response-cacheandkv-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 arelive-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.s-maxageis what the shared response cache reads. Without it, a public GET is not cached at all.stale-while-revalidatelets the edge serve a slightly stale response while it refreshes in the background, which is ideal for browse and reference data.private, no-storeis the explicit opt-out for anything customer, session, or payment scoped.
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.
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.
Next steps
API routes
The public surface and how routes emit their cache headers.
Public API
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.