ConceptsPerformance & caching

Performance and caching

Flightdeck’s caching story is deliberately small and honest: one conditional revalidation contract on the site read, mechanical CDN URL resolution for assets, and an explicit last-good fallback in the renderer. Almost nothing else is cached at the platform layer today, and this page says exactly where those lines are so you can build a fast storefront without assuming caching that is not there.

The site read is live, with ETag/304

The store manifest — the authored sections, theme tokens, and nav that define a storefront — is served by two public routes in modules/stores:

  • GET /v1/resolve?hostname= or ?tenant= — the renderer’s resolution path.
  • GET /v1/tenants/{t}/site — the developer-contract site read (typed in the SDK as store.site.get()).

Both are live (they serve the active, published manifest) and both support conditional revalidation with an ETag:

The ETag is the manifest’s content hash (row_hash = sha256 of the canonicalized manifest), computed at publish time — not updated_at. That means the ETag is stable across re-serves and even across a re-publish of byte-identical content; the only thing that changes it is a manifest whose content actually differs. Send the last ETag you saw as If-None-Match and you get a bodyless 304 whenever nothing changed.

These routes emit no Cache-Control header. Revalidation is purely ETag/304 — there is no platform-set TTL telling a shared cache how long it may serve a stored copy without asking. Cacheability at the CDN layer is left to the substrate (see below), which is not yet wired.

Both routes are fail-closed, never fabricated absence: a read failure is 503 (“resolution failed — this is NOT a missing store”), a genuinely unknown host or tenant is 404, and /v1/tenants/{t}/site returns 404 for any store that is not live (no draft or suspended leak). The preview route (/v1/preview-resolve) is the opposite of cacheable on purpose: it sets cache-control: no-store and emits no ETag, so a preview can never pollute a cache that feeds live rendering.

The manifest last-good cache lives in the renderer

There is no last-good cache inside modules/stores — the fallback is in the fleet renderer (surfaces/storefront). Its manifest source keeps an in-process map keyed by host:<host> or tenant:<tenant>, holding the last successfully-fetched { etag, resolution }. It is process-lifetime and not time-boxed — there is nothing to expire on a timer, because only a publish changes the ETag.

The value of that cache is graceful degradation. The renderer sends If-None-Match with cache: "no-store" on every resolve, and:

  • 304 -> serve the cached resolution (the common fast path).
  • transport failure, non-OK status, or an unparseable manifest, with a cached entry -> log and serve the stale manifest rather than error a live storefront. With nothing cached, it throws (fail-closed).
  • 404 -> genuine absence: drop the cache key and return nothing. A 404 is never served stale — an unpublished or removed store must not keep rendering from a stale copy.
⚠️

The last-good cache is per renderer process and in memory. It survives an API hiccup, not a renderer restart or a scale-out to a fresh instance. It is a resilience mechanism, not a performance cache you should size capacity around.

Asset and image resolution

Image URLs are resolved mechanically from a content-addressed asset ref — there is no service round-trip and no signed URL on the read path. An asset ref has the shape asset:<tenant>/<sha256>.<ext>, where the extension is derived from the declared content type (never the client filename) and the sha256 addresses the exact bytes, so re-uploading identical bytes is idempotent.

Two resolvers turn a ref into a URL, both pure string rewrites to a CDN origin:

  • Server-side, in commerce. Every public catalog response is passed through withImageUrls, which keeps the raw image_asset_ref on the object and adds a resolved image_url of the form <assetBaseUrl>/stores/<tenant>/assets/<sha256>.<ext>. If the base URL is unset or the ref is malformed, image_url is null — honest absence, never a broken URL.
  • Read-side, in the storefront. The renderer resolves the same ref against NEXT_PUBLIC_ASSET_BASE_URL (a CloudFront/CDN origin); unset falls back to same-origin for demo and fixture assets. Sized variants (<sha256>.w{320|640|1280}.webp, written by an optimize-asset step) are requested only when NEXT_PUBLIC_ASSET_VARIANTS=1; SVGs never get variants.

Because resolution is a pure rewrite, the CDN serves assets directly and the platform is never in the image read path. The storefront also uses a custom Next image loader (no Next image-optimization server), so image URLs go straight to the CDN or origin.

Pagination and its cost

All list endpoints are offset/limit — there is no cursor pagination anywhere today. Non-integer or negative inputs are clamped before they reach SQL, so a malformed ?limit=abc never errors and never runs an unbounded query.

EndpointDefault limitMax limitNotes
Catalog search GET /v1/tenants/{t}/catalog/search2050Max offset 10,000; returns result_count, limit, offset
Merchant customers GET /v1/merchant/customers25100Returns total, limit, offset
Public product list GET /v1/tenants/{t}/catalog/productsNot paginated: returns the full active-product list
⚠️

The public product-list and collection-detail reads are unpaginated and fetch each product’s variants with one query per product (an N+1 pattern). For a large catalog, prefer catalog search (which is paginated and bounded) over listing every product, and lean on the ETag’d site read plus the CDN for the parts of the page that do not change per request.

What the renderer does and does not cache

The fleet renderer’s caching posture, precisely:

  • Manifest resolution is cached (the in-process ETag cache with last-good fallback, above). This is the one cached fetch.
  • Catalog and COA reads are not cached. The renderer fetches them over HTTP with cache: "no-store" on every call — no ETag revalidation, no in-process cache. A non-OK or unparseable catalog read throws (never degraded to an empty list); a genuine empty result is an honest [].
  • Pages are effectively dynamic. There is no revalidate / ISR config in the storefront; only robots and sitemap are force-dynamic. Gate-verify and coupon-preview calls are no-store as well.

The renderer reaches the commerce and inventory routes over HTTP (contract identity — it is the first headless consumer of the same routes the SDK exposes), not through an internal back channel, so what you can build against those routes is what the renderer itself runs on.

The honest gaps

  • No platform Cache-Control. Neither the site read nor the catalog reads emit a caching directive. The site read supports ETag/304; the catalog reads support neither ETag nor Cache-Control — every catalog read is a fresh origin hit today.
  • The CDN/edge substrate is undecided and unwired. The renderer is kept substrate-agnostic (Vercel versus CloudFront+OpenNext) on purpose (ADR-0003); the edge-cache decision is a measured checkpoint during the store ramp, and the AWS infrastructure work is gated behind Gate 0. Do not assume an edge cache is in front of these routes.
  • Last-good is in-memory and per-process. It does not survive restarts or cover a cold instance.

Building a fast storefront on top

Practical guidance given the above:

  1. Revalidate the manifest with If-None-Match. Cache the ETag you last saw and send it — a 304 is the cheapest possible refresh, and the manifest only changes on publish.
  2. Cache catalog reads yourself. The platform does not cache them and sets no validators, so put your own cache (your BFF, your framework’s data cache, or a CDN with rules you own) in front of catalog and product reads, keyed by tenant and query.
  3. Let the CDN serve images. Asset URLs resolve straight to the CDN origin; request sized webp variants where they exist. The platform is never in the image path.
  4. Prefer paginated search over full product lists for large catalogs, and bound your limit.
  5. Keep the storefront token server-side. All of these reads are meant to run from your server, not the browser (see Security).