> ## Documentation Index
> Fetch the complete documentation index at: https://voyant.travel/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Caching

> The caching architecture, the public route cache policy, cache headers and invalidation, and guidance for storefront performance.

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.

<Note>
  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.
</Note>

## Backends through one small contract

Voyant exposes a narrow shared cache contract (`KVStore`) so the same code can run against different backends:

```ts theme={null}
await store.get(key)
await store.put(key, value, { expirationTtl })
await store.delete(key)
```

A higher-level `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.

| Backend           | Good for                                                                                                                                         | Default in              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
| **Cloudflare KV** | Read-heavy, staleness-tolerant reference caching: storefront settings, market and config lookups, localization bundles, cacheable query results. | Cloudflare deployments. |
| **In-memory**     | Local development only.                                                                                                                          | Local and dev.          |

<Note>
  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.
</Note>

<Warning>
  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. Do not use the cache as the source of truth for anything correctness depends on.
</Warning>

Backend selection is a deployment concern behind the shared interface, not a reason to fork the platform. KV matches the Cloudflare 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.

```mermaid theme={null}
flowchart TD
    req["GET /v1/public/*"] --> q1{"Emits Cache-Control: public, s-maxage?"}
    q1 -->|no| live["Not cached, live DB path"]
    q1 -->|yes| q2{"Response has Set-Cookie?"}
    q2 -->|yes| live
    q2 -->|no| store["Stored in response cache / env.CACHE KV"]
    store --> ttl["Served until s-maxage TTL expires"]
```

### Policy classes

Every public route is classified into one cache policy before it is built.

| Policy class            | Meaning                                                                                                                                                                   |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shared-response-cache` | Non-personalized, stale-tolerant public GET. Emits `Cache-Control: public, s-maxage=..., stale-while-revalidate=...`.                                                     |
| `kv-read-model`         | Public 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-store`      | Bearer-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-correctness`   | Volatile price quote, hold, booking, payment mutation, eligibility, or write flow where stale data changes correctness. Never cached.                                     |
| `index-backed`          | Search 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.

<Note>
  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.
</Note>

## Cache headers and invalidation

The header you emit is the contract that decides caching. Three headers matter.

```ts theme={null}
// 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.

<Warning>
  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.
</Warning>

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.

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Tip>
  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.
</Tip>

## Next steps

<CardGroup cols={2}>
  <Card title="API routes" icon="globe" href="/docs/guides/consume-the-api">
    The public surface and how routes emit their cache headers.
  </Card>

  <Card title="Public API" icon="globe" href="/docs/platform/public-api">
    The customer-facing surface that benefits most from caching.
  </Card>

  <Card title="Services" icon="gears" href="/docs/concepts/how-it-is-built">
    The live path behind every cache, correct on every miss.
  </Card>

  <Card title="Architecture" icon="sitemap" href="/docs/concepts/architecture">
    The per-organization runtime where the cache backend lives.
  </Card>
</CardGroup>
