fix(cloudflare): default image transform quality to 85#2037
Conversation
The Images binding applies no quality default of its own and encodes near-losslessly when quality is omitted, so renditions without an explicit `q` came out several times larger than the original (a 2048px WebP at ~900 KB instead of ~100 KB with q=85) — defeating the point of the transform. Astro's image service only appends `q` when a quality is explicitly configured, so this was the common path for every EmDash media rendition. parseTransformParams now falls back to DEFAULT_TRANSFORM_QUALITY (85, matching Cloudflare's URL-based image-resizing default) and the Cloudflare endpoint always sends an explicit quality to the binding.
🦋 Changeset detectedLatest commit: 59da73a The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Approach
This is the right change, solving the right problem, in the right place. The Cloudflare IMAGES binding applies no quality default of its own and encodes near-losslessly when output.quality is omitted; since the EmDash Image component never sets a quality and Astro's image service only appends q= when one is explicitly configured, every media rendition on Cloudflare was going out near-lossless. Defaulting parseTransformParams to DEFAULT_TRANSFORM_QUALITY (85, matching Cloudflare's URL-based transform default) is the minimal, correctly-located fix — and it's the only consumer of parseTransformParams (the Node/sharp endpoint uses service.parseURL and is correctly left untouched, with sharp applying its own encoder defaults).
What I checked
- Traced every consumer of
parseTransformParams/ImageTransformOptions: only the Cloudflare endpoint. Makingqualityrequired onImageTransformOptionsis safe — no other construction site exists. - Confirmed the Node endpoint (
packages/core/src/astro/image-endpoint.ts) doesn't touchparseTransformParams; it goes throughservice.parseURL(url, imageConfig), so it's unaffected and still gets sharp's own defaults. Image.astro/responsive.tsconfirm the/_imageURLs generated by the component never carryq=, so the request URL is unchanged by this fix.- Quality validation (1–100, integer) is unchanged and still runs when
qis supplied; explicitq=still wins. PNG output ignores quality (lossless), so always sending it is harmless. - Test updated to assert the default is present — this is a reproducing test (it would have failed before, when
qualitywasundefined). - Changeset present for both touched packages (
@emdash-cms/cloudflare+emdash), leads with "Fixes", describes the observable effect. No SQL/i18n/locale concerns apply.
The code is correct and clean. One non-blocking operational caveat below.
Caveat
Because the /_image URL (the cache key) is unchanged — the component never appends q= — and the endpoint still returns Cache-Control: public, max-age=31536000, immutable, renditions already cached at the browser/Cloudflare edge for a given URL keep being served for up to a year. The fix therefore takes effect for newly-rendered / not-yet-cached URLs, but does not retroactively replace already-cached oversized renditions. Worth a cache purge on deploy if immediate relief is wanted.
| // Always send a quality: the Images binding has no default and encodes | ||
| // near-losslessly without one, producing renditions several times the | ||
| // size of the original. | ||
| const output: ImageOutputOptions = { format: outputMime, quality }; |
There was a problem hiding this comment.
[suggestion] This now always sends quality to the binding, but the request URL (the cache key) is unchanged — the EmDash Image component never appends q= to the /_image URL. Combined with the Cache-Control: public, max-age=31536000, immutable returned below (line 102), renditions already cached at the browser/Cloudflare edge for a given media+width URL keep being served (oversized) for up to a year and won't be regenerated. The fix only takes effect for URLs not yet cached.
Not a code defect — the immutable cache is pre-existing and inherent to any output change at a fixed URL — but worth noting so the deploy can purge the Cloudflare cache for these image URLs, or the cache key could be changed (e.g. include q in the URL the component generates) if immediate relief is desired.
There was a problem hiding this comment.
Good call — noted in the changeset that previously-served renditions stay cached under the immutable header until they expire, with the recommendation to purge /_image* URLs after upgrading. I kept the request URL unchanged (no q token): the component only emits widths it wants, and a default-quality change is a one-time migration best handled by a cache purge rather than permanently keying URLs on an internal encoding detail.
khoinguyenpham04
left a comment
There was a problem hiding this comment.
Thanks for this fix @swissky . A few things, could we make the default quality configurable at the site level?
Also, Cloudflare uses explicit PNG quality to enable PNG8, so we should only apply the default to WebP, AVIF, and JPEG. Keeping quality optional would avoid breaking the public type too.
Previously-served oversized renditions stay cached under the immutable header until they expire; flag the purge step for upgraders (emdashbot).
Addressing review from khoinguyenpham04: an explicit PNG quality switches the Images binding to lossy PNG8, so applying the lossy default to PNG would silently degrade a lossless format. parseTransformParams now leaves quality undefined unless requested (keeping ImageTransformOptions.quality optional, so the public type is unchanged), and a new resolveTransformQuality helper applies DEFAULT_TRANSFORM_QUALITY only to lossy WebP/AVIF/JPEG while letting an explicit ?q= win for every format including PNG.
|
@khoinguyenpham04 thanks for the review — both points addressed in 59da73a: PNG8 / format-scoped default: Optional quality / public type: Coverage: added On site-level configurability: agreed that'd be useful, but I'd rather land it as a follow-up — it needs a settings surface plus a cache-busting story (quality is baked into the immutable-cache key). This PR stays the minimal correctness fix; happy to open a Discussion for the configurable-quality feature if you want it tracked. |
What does this PR do?
Fixes oversized image renditions on Cloudflare Workers. The
IMAGESbinding applies no quality default of its own and encodes near-losslessly whenoutput.qualityis omitted. Since Astro's image service only appendsq=to/_imageURLs when a quality is explicitly configured — and the EmDashImagecomponent never sets one — this was the common path for every media rendition on Cloudflare.Measured on a production Worker (same source JPEG, 122 KB original):
q(before)q=85(after)The "optimized" rendition was up to 7× larger than the original it replaced.
Fix:
parseTransformParamsnow falls back to a newDEFAULT_TRANSFORM_QUALITY(85, matching the default Cloudflare applies to URL-based image transformations) so the Cloudflare endpoint always sends an explicit quality to the binding. An explicitq=in the request still wins. The Node/sharp endpoint is unaffected — it goes throughservice.parseURL, and sharp applies its own encoder defaults.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
packages/coreunit suite: 4721 passed | 3 skipped (355 files). The updatedparseTransformParamstest asserts the quality default is always present.