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 asstore.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. A404is 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 rawimage_asset_refon the object and adds a resolvedimage_urlof the form<assetBaseUrl>/stores/<tenant>/assets/<sha256>.<ext>. If the base URL is unset or the ref is malformed,image_urlisnull— 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 whenNEXT_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.
| Endpoint | Default limit | Max limit | Notes |
|---|---|---|---|
Catalog search GET /v1/tenants/{t}/catalog/search | 20 | 50 | Max offset 10,000; returns result_count, limit, offset |
Merchant customers GET /v1/merchant/customers | 25 | 100 | Returns total, limit, offset |
Public product list GET /v1/tenants/{t}/catalog/products | — | — | Not 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; onlyrobotsandsitemapare force-dynamic. Gate-verify and coupon-preview calls areno-storeas 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 norCache-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:
- Revalidate the manifest with
If-None-Match. Cache the ETag you last saw and send it — a304is the cheapest possible refresh, and the manifest only changes on publish. - 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.
- Let the CDN serve images. Asset URLs resolve straight to the CDN
origin; request sized
webpvariants where they exist. The platform is never in the image path. - Prefer paginated search over full product lists for large catalogs, and
bound your
limit. - Keep the storefront token server-side. All of these reads are meant to run from your server, not the browser (see Security).