Related: Publish Flow · Billing & Cost · Auth Tokens
The public API (/api/v1/**) is served via Firebase Hosting rewrites to the publicv1 Cloud Function. All CDN endpoints use a cache-version redirect pattern to allow Firebase Hosting's edge CDN to cache responses long-term while still supporting instant invalidation after publish.
Every CDN endpoint follows this flow:
Client request (no cv or stale cv)
→ Function reads Storage cache.json metadata
→ 302 redirect to same URL + ?cv=<generation> ← cached 60s by default (per-token override)
→ Client follows redirect
→ Function returns actual JSON ← cached 7 days (browser and shared CDN)
The cv value is the Firebase Storage generation number of the cache marker file:
- Content:
spaces/{spaceId}/contents/cache.json - Translation:
spaces/{spaceId}/translations/cache.json
Whenever the marker is rewritten, its generation increments. The content marker is rewritten by the content.onwrite trigger on every content write (not only publish); the translation marker is rewritten on translation publish and on translation draft generation. See Publish Flow. All existing cv values become stale — the next request from any client triggers a redirect to the new cv.
| Scenario | max-age |
s-maxage |
Who respects it |
|---|---|---|---|
| Redirect (cv missing or stale) | 60s (default) | 60s (default) | CDN edge + browser |
| Content/Translation/Links response | 7 days | 7 days | browser / CDN |
Asset response (incl. /original, /download) |
365 days | 365 days | browser / CDN |
Asset 304 Not Modified |
365 days | 365 days | browser / CDN |
Asset canonical-size redirect (w/h above source) |
365 days | 365 days | browser / CDN |
| 400 — invalid asset transform param | 1 hour | 1 hour | browser / CDN |
| 404 — space not found | 7 days | 7 days | browser / CDN |
| 404 — content cache marker missing / content not found on disk (content routes only) | 10 min | 10 min | browser / CDN |
| 404 — translation/links cache marker missing, translation file missing, slug not found | (no Cache-Control header sent) |
||
| 404 — asset: Firestore doc missing (asset genuinely does not exist) | 7 days | 7 days | browser / CDN |
| 404 — asset: Firestore doc exists but Storage object missing (upload in progress) | no-cache |
no-cache |
browser / CDN |
Redirect TTL is a flat default (
CACHE_REDIRECT_MAX_AGE_DEFAULT), not split by published/draft. It can be overridden per-token via thecacheTtlfield onTokenV2— see Auth Tokens — wherecacheTtl: 0disables caching entirely (Cache-Control: no-cache)."404 responses" is not a single behavior — it depends on which lookup fails (see rows above); some 404s carry no
Cache-Controlheader at all.
Constants are defined in functions/src/config.ts:
CACHE_MAX_AGE = DAY * 7 // 604800s
CACHE_SHARE_MAX_AGE = DAY * 7 // 604800s
CACHE_ASSET_NOT_FOUND_MAX_AGE = DAY * 7 // 604800s — 404 for an asset with no Firestore doc
CACHE_ASSET_MAX_AGE = DAY * 365 // 31536000s
CACHE_REDIRECT_MAX_AGE_DEFAULT = MINUTE // 60s — default redirect TTL, overridable per-token via `cacheTtl`
CACHE_BAD_REQUEST_MAX_AGE = HOUR // 3600s — cached 400 for rejected asset params| Endpoint | Auth | cv source |
|---|---|---|
GET /api/v1/spaces/:spaceId/translations/:locale |
Token (TRANSLATION_PUBLIC or DRAFT) | translations/cache.json |
GET /api/v1/spaces/:spaceId/links |
Token (CONTENT_PUBLIC, CONTENT_DRAFT or DEV_TOOLS) | contents/cache.json |
GET /api/v1/spaces/:spaceId/contents/slugs/*slug |
Token (CONTENT_PUBLIC or DRAFT) | contents/cache.json |
GET /api/v1/spaces/:spaceId/contents/:contentId |
Token (CONTENT_PUBLIC or DRAFT) | contents/cache.json |
GET /api/v1/spaces/:spaceId/assets/:assetId |
None | N/A (no cv) |
GET /api/v1/spaces/:spaceId/assets/:assetId/original |
None | N/A (no cv) |
GET /api/v1/spaces/:spaceId/assets/:assetId/download |
None | N/A (no cv) |
One URL, one output. Every transform parameter is either honoured exactly as given or rejected
with 400 — nothing is silently adjusted. That rule exists for the cache, not for tidiness: any
value the function quietly rewrites means two URLs resolving to identical bytes, and the CDN keys on
the URL it was handed, so each alias is a separate edge entry and a separate run of sharp.
Concretely:
?w=/?h=above the source redirect to the size the source can produce.?w=5000on a 400 px asset returns302 → ?w=400. No upscaling, and every oversized spelling collapses onto one canonical URL rather than returning identical bytes under many — the same trickcvuses. With both dimensions the box shrinks proportionally, sofitsemantics survive. An asset with no recorded dimensions is served as requested, since the source size is unknown.MAX_OUTPUT_DIMENSION(8192 px) rejects rather than clamps.?w=9000is a400, checked before the redirect. The ceiling bounds the decoded bitmap sharp must hold — an 8192 px edge is ~200 MB of raw pixels — so raising it means revisitingmemory/concurrencyinfunctions/src/v1.tstoo.- Only a canonical decimal integer is accepted for
w/h/q.w=400.9,w=0400andw=4e2are all rejected, because each would render identically tow=400under a different cache key.
The stored original stays reachable via the /original route. See
Assets — Parameter Validation for the full matrix.
No format conversion happens implicitly, but quality is normalised. A request without ?f=
keeps the stored format and still re-encodes a still raster at that format's default quality, so a
bare URL is a rendition rather than the stored file. Animations, GIF, SVG and video are served as
stored. Passing ?f=webp or ?f=avif cuts transfer size further, and is opt-in. See
Assets — Output Format for the full matrix.
Responses carry an ETag derived from the object's md5Hash plus a suffix describing the
effective encode — target format, quality, dimensions and fit. A matching If-None-Match
returns 304 before any download or re-encode. The suffix is built from the resolved encode
rather than the raw query so that two spellings producing identical bytes share an entry, while a
rendition can never collide with the orig tag the passthrough routes use.
functions/src/v1.ts mounts compression() across the whole API, so every JSON response is gzipped
when the client sends Accept-Encoding: gzip. Measured against the demo dataset, a translation
locale file goes from ~490 KB to ~93 KB and the OpenAPI document from ~40 KB to ~5 KB — around 80%
off the wire for the CDN endpoints overall.
The filter is the middleware's default, which decides purely from the response Content-Type via
compressible. That is what keeps the asset route out of it without naming it: image/*, video/*,
application/zip and application/pdf are already marked incompressible, so re-encoding an
already-compressed JPEG never happens, and a new asset MIME type cannot accidentally opt in.
image/svg+xml is the deliberate exception — it is text, so it does get compressed.
Two consequences worth knowing:
- Compressed responses carry
Vary: Accept-Encoding, so the CDN keys a separate edge entry per encoding. In practice that is two (gzip and identity), since every browser and every mainstream HTTP client advertises gzip. - The 1 KB default threshold means small bodies — most notably the
HttpsError404s — are sent uncompressed, where gzip framing would only add bytes.
When content is published all consumers have a stale cv. Without a cached redirect, every consumer simultaneously invokes the Function → Storage → Firestore chain. The redirect cache (60s default, per-token tunable) limits the stampede to one wave per CDN edge node.
See Billing & Cost for impact analysis and Publish Flow for when invalidation happens.
| Published | Draft | |
|---|---|---|
version query param |
absent | version=draft |
| Storage path | {id}/{locale}.json |
{id}/draft/{locale}.json |
| Redirect TTL | 60s default (overridable via token cacheTtl) |
60s default (overridable via token cacheTtl) |
| Required permission | *_PUBLIC or *_DRAFT |
*_DRAFT or DEV_TOOLS |
functions/src/v1/cdn.ts— all CDN route handlersfunctions/src/config.ts— cache TTL constantsfunctions/src/v1/middleware/query-auth.middleware.ts— token auth per requestfirebase.json— Hosting rewrite rules