ADR-057: Per-Image Size Budget Gate
Status
Section titled “Status”Accepted
Context
Section titled “Context”The single largest real-world performance regression an adopter of this
starter hit was a 1.4MB hero PNG — one oversized asset cost 27 Lighthouse
points on its own. The template already gates JavaScript (< 160KB raw,
enforced in CI), CSS, and Lighthouse category scores, and .claude/stack.md
even documents an image budget — “Images: < 200KB each after optimisation”.
But that image budget was never enforced. The only image tooling was
images:analyze (scripts/src/optimize-images.ts), which reports sizes and
prints recommendations without ever failing the build. Analysis without
enforcement directly contradicts the halt-on-violation philosophy
(ADR-039): a budget nobody enforces is
a suggestion, and suggestions do not stop a 1.4MB PNG from shipping.
Images are the number-one perf killer for adopters, so the gap matters more than the equivalent gap would for, say, CSS. We need a gate that fails CI when a raster asset exceeds the documented ceiling.
Decision Drivers
Section titled “Decision Drivers”- Halt-on-violation parity: images should be gated like JS/CSS/Lighthouse, not merely analysed (ADR-039).
- Catch the real failure mode: an oversized raster served as-is
(
public/) or committed to source, regardless of format. - Low false-positive rate: must not break legitimate assets (OG cards, touch icons) that are already well under budget.
- Configurable: adopters with different constraints can raise or lower the ceiling without editing the script.
- Consistency: mirror the existing pure-function-plus-CLI script pattern
(
check-doc-counts.ts,check-version-consistency.ts) wired into CI.
Considered Options
Section titled “Considered Options”Option 1: Per-asset size ceiling on source raster, enforced in CI
Section titled “Option 1: Per-asset size ceiling on source raster, enforced in CI”Description: A script scans public/ and src/ for raster files
(.png .jpg .jpeg .webp .avif .gif), asserts each is under a configurable
per-file ceiling (default 200KB, matching the documented budget), and exits
non-zero with a per-file violation report. SVG is unrestricted; format is
advisory in the report, not hard-enforced.
Pros:
- Directly catches the actual failure mode (one giant asset) in any format.
- Zero false positives on the current demo (largest asset is 80KB).
- Reuses the established gate pattern; trivial CI cost (a
statwalk).
Cons:
- Size-only: does not enforce format choice (AVIF/WebP vs PNG) as a hard rule.
Option 2: Hard-enforce format policy (reject PNG/JPEG over a threshold)
Section titled “Option 2: Hard-enforce format policy (reject PNG/JPEG over a threshold)”Description: Fail the build on any photographic raster not shipped as AVIF/WebP.
Pros:
- Pushes adopters toward modern formats aggressively.
Cons:
- High false-positive risk: the generated OG cards and touch icon are legitimate PNGs. Format is context-dependent; a hard rule punishes correct lossless use (icons, screenshots) and would need a sprawling exception list.
Option 3: Gate the build output (dist/) instead of source
Section titled “Option 3: Gate the build output (dist/) instead of source”Description: Scan dist/ after build to catch what actually ships.
Pros:
- Measures post-optimisation reality, including any raster fallbacks Astro emits.
Cons:
- Requires a full build before the gate can run (slower feedback).
- Astro’s hashed output is harder to map back to a source file for a human-actionable “fix this file” message.
- Does not catch an oversized asset in
public/any earlier than source scan does (public/is copied verbatim).
Decision
Section titled “Decision”We will implement Option 1: a scripts/src/check-image-budget.ts gate that
enforces a configurable per-file size ceiling on raster assets under public/
and src/, wired into GitHub Actions CI alongside the JS bundle-size gate.
- Default ceiling: 200KB per raster file — the number
.claude/stack.mdalready documents. Override withIMAGE_BUDGET_KB=<kb>. - Scope:
.png .jpg .jpeg .webp .avif .gifunderpublic/andsrc/. SVG is unrestricted (vector; size is not a proxy for weight the same way). - Format policy (documented, advisory in the report — not a hard gate): prefer AVIF/WebP for photographic raster; PNG only where lossless is justified (icons, screenshots) and under the cap. The violation report suggests a format fix per extension.
- Anti-pattern called out explicitly: generating multi-MB PNG fallbacks
alongside AVIF/WebP. Astro’s
<Picture>/<Image>pipeline emits modern formats; a heavyweight PNG fallback re-introduces exactly the weight this gate exists to stop. Adopters must not add such fallbacks. This is enforced at the build-output level too: CI runs the same gate again withIMAGE_GATE_ROOTS=distafter the build, so any oversized emitted raster fails regardless of source (added alongside ADR-058).
Implementation Details
Section titled “Implementation Details”// Pure, unit-tested core — the CLI wires it to the filesystem.export function findImageBudgetViolations( assets: ImageAsset[], budgetBytes: number,): ImageAsset[] { return assets .filter((asset) => asset.bytes > budgetBytes) .sort((a, b) => b.bytes - a.bytes); // worst offender first}Consequences
Section titled “Consequences”Positive
Section titled “Positive”- The documented 200KB image budget is now enforced, not aspirational.
- A 1.4MB-hero-PNG regression fails CI with a clear, per-file report instead of silently costing Lighthouse points in production.
- The ceiling is one env var away from adopter-specific tuning.
Negative
Section titled “Negative”- Size-only enforcement: a correctly-sized but suboptimally-formatted image (e.g. a 150KB PNG that could be a 40KB WebP) passes the gate. The report flags it; the gate does not fail on it.
Neutral
Section titled “Neutral”images:analyzeremains as the richer advisory tool;images:gateis the pass/fail companion. Two scripts, two jobs (analyse vs enforce).
Validation
Section titled “Validation”- Metric 1: a fixture image over the ceiling fails the gate (exit 1); a compliant fixture passes (exit 0). Covered by unit + fixture tests.
- Metric 2: the current demo passes the gate (largest raster is 80KB).
- Metric 3: CI fails on a PR that adds an over-budget raster.
References
Section titled “References”- ADR-039: Halt-on-Violation Enforcement
- ADR-052: Script Taxonomy
.claude/stack.md— Performance Budgets (documented image budget)scripts/src/optimize-images.ts— the pre-existing advisory analyser
By default the gate scans source (public/, src/) so feedback needs no build
and violation messages point at a real source file. CI additionally runs it with
IMAGE_GATE_ROOTS=dist after the build to catch oversized emitted raster (e.g.
PNG fallbacks), as called out in the format anti-pattern above.
Enforcement
Section titled “Enforcement”- Testable consequences:
- TC-1: every raster in
public//src/and in the build output is under the per-file ceiling.
- TC-1: every raster in
- Checks:
- TC-1 →
images:gate(source + dist) in CI (status: block, pre-existing gate)
- TC-1 →
- Not machine-checkable: whether the configured ceiling remains appropriate is a maintainer judgment (the value is config, not code).
- Graduation log: (empty at creation; entries added when a check changes status)
Date: 2026-07-02
Participants: Engineering
Outcome: Accepted