Repository navigation
Measure whether the image CDN bill is egress- or invocation-bound #25
Description
Activity
- addedquestionFurther information is requestedFurther information is requested
on Sep 12, 2026 Checked the bill — the question is real but not urgent
Read the Cloud Billing overview for the project (September 2026):
Cost, 1–12 Sept 3.82 € gross · 1.79 € savings · 2.03 € net Forecast, full month 7.91 € — 58 % of August Firebase console "no billable Firebase services used recently" (Blaze) So the whole GCP bill for this project is single-digit euros a month, and falling. At that scale the egress-versus-invocations trade this issue was opened to settle does not change any decision: the
&size=work is free upside with nothing to weigh against it, and collapsing rungs to save cache keys would be optimising a rounding error.Downgrading this from "decide before going further" to "worth knowing eventually". Revisit it if the bill starts climbing — most plausibly when the catalogue gets real traffic, since it is the one surface that is never disk-cached and therefore pure network on every visit.
Still unmeasured
The per-SKU split (Cloud Run invocations + CPU-seconds vs Cloud Storage egress). The billing Reports page would not finish loading through browser automation — repeated script-injection timeouts, it is a heavy page. Anyone picking this up should open it by hand: Billing → Reports, filter to this project, group by SKU.
The two rejected options (
&stream=1, pre-caching a smaller rendition) stay rejected — see the issue body. Neither rejection depended on the split; both were rejected on shape, not on scale.Correcting two claims I made above, both measured in the running app rather than reasoned about:
- "The catalogue is pure network on every visit" was wrong. A cold reload fetched 25 catalogue images and all 25 came from Chromium's HTTP disk cache — zero network, zero bytes, and the 302 was not replayed.
immutable, max-age=1ydoes the job. The accurate statement is that it is not in the app's own cache, so it is subject to browser eviction rather than kept forever. - The byte figures are JPEG sizes; the app actually receives AVIF. The proxy negotiates on
Acceptand served.avif25 times out of 25. Ratios between rungs hold; absolute numbers are pessimistic.
Getting there took four wrong measurements —
transferSizeis zeroed cross-origin, 302s arrive asredirectResponseand notresponseReceived, memory-cache hits have their own event, and I was clearing the counter after the images had already loaded. Every zero before this one was an instrument fault, not a fact.- "The catalogue is pure network on every visit" was wrong. A cold reload fetched 25 catalogue images and all 25 came from Chromium's HTTP disk cache — zero network, zero bytes, and the 302 was not replayed.
Studio now asks the image CDN for the rendition that fits each slot (
&size=), instead of taking thelargedefault everywhere. That cuts egress sharply — the catalogue table went from thelargerung tocompact, roughly 10x fewer bytes per row — but it trades one thing we have not measured.The question
Is the CDN bill dominated by Storage egress, or by
/imginvocations?Asking several renditions of the same product multiplies cache keys: a product that used to be one URL can now be requested as
small,compactandlarge— three edge-cache entries and three 302s on first miss instead of one.small/compactinto one, so every list shares a single key.Rough intuition, not yet checked: a 302 is a few hundred cached bytes with no Storage read, so an invocation should be far cheaper than the bytes it saves. But "should be" is not a billing line.
How to answer it
GCP billing, broken down by SKU for this project: Cloud Run invocations + CPU-seconds for the
/imgservice, against Cloud Storage network egress for thefilament/*objects. The Reports page has to be opened by hand — it would not finish loading through browser automation.What is already known, measured in the running app
The catalogue IS cached — by Chromium, not by us. Measured over a cold reload: 25 catalogue images, 25 served from the HTTP disk cache, 0 from the network, 0 bytes, and the 302 was not replayed either. The images carry
cache-control: immutable, max-age=1y, so once fetched they do not come back. An earlier version of this issue said the catalogue was "pure network on every visit" — that was wrong.What is true is narrower: the catalogue is not in the app's OWN disk cache (
preCacheImagesonly warms the inventory). The difference that matters is that ours is permanent and version-keyed, while Chromium's is bounded and evicts — across 13 408 products x several renditions it will evict, and then re-fetch. Not "never cached", but "cached until the browser needs the room".Everything is served as AVIF. The proxy negotiates on Chromium's
Acceptheader and returnsfilament/<rung>/<id>.avif— 25 of 25 in the same measurement. Every byte figure quoted for the rungs (33 kB forlarge, 3.4 kB forcompact, …) was measured with curl WITHOUT that header, so they are JPEG sizes: the ratios between rungs hold, the absolute numbers are pessimistic.Settled, do not re-litigate
&stream=1(serve the bytes instead of the 302) was considered and rejected on cost. The endpoint supports it and it removes a round-trip and a second origin — but it moves the image egress off Storage and onto the function, and bills CPU in proportion to the bytes served. The redirect is the cheap shape by design: a tiny cached response, with the payload coming straight from Storage. Recorded incdnImg()inrenderer/inventory.jsso it is not re-discovered as a good idea.Pre-caching a smaller rendition was also rejected. The local disk cache holds ONE rendition per product and the detail panel reads it too, so warming
mediumwould save 23 kB once and then cost a full-size fetch on every panel open. Fetched once and free forever afterwards is the lowest TOTAL egress. Recorded inpreCacheImages().Neither rejection depended on the egress/invocation split — both were rejected on shape, not on scale.
Reference
The rendition ladder, the 302 behaviour and the HTTP/2 note are documented in
CLAUDE.md-> API base -> Product images. Source of truth for the sizes is theSIZESmap in the backend repo'sfunctions/index.js.