Tier 6: performance optimisations (lazy-load D3, WebP images, self-hosted fonts, CI size budget, cache headers)#70
Merged
felipebalbi merged 10 commits intoMay 15, 2026
Conversation
Cargo's defaults aren't tuned for wasm bundle size. Switching the
release profile to:
opt-level = "z" # size over speed
lto = "fat" # cross-crate inlining + dead-code elim
codegen-units = 1 # let LTO see the whole program
panic = "abort"
shrinks the shipped wasm from 786 KB -> 450 KB raw and 286 KB ->
182 KB gzipped, a ~37 % reduction over the wire. `strip = true`
remains so debug sections don't bloat the binary.
panic = "abort" is safe here because we never catch panics: the
top-level Leptos shell installs `console_error_panic_hook` which
runs before abort and surfaces the panic in the browser console
exactly as before.
Tests
-----
* `cargo test --release` -- 46 host tests pass.
* `cargo clippy --target wasm32-unknown-unknown --all-targets --no-deps -- -D warnings` -- clean.
* `trunk build --release` -- succeeds; wasm payload measured above.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The `web-sys` direct dependency declared `features = ["Document", "Window", "console"]` for the small amount of code in `components/repo_view.rs` that calls `web_sys::window()`. Audit shows those features are already pulled in transitively by `leptos` (via `leptos` -> `tachys` -> `web-sys`), so the explicit list is redundant. Drop the feature list and add a comment explaining why we still depend on web-sys directly (the `js_sys` re-export and `window()`). Bundle impact: zero. LTO had already removed the unused symbols, so the wasm payload is byte-identical (450 KB raw / 182 KB gz). This is a clarity-only change. Tests ----- * `cargo test --release` -- 46 host tests pass. * `cargo clippy --target wasm32-unknown-unknown --all-targets --no-deps -- -D warnings` -- clean. * `trunk build --release` -- succeeds; wasm hash unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The file `public/images/patina_header.svg` weighed 5.9 MB (it contains two large embedded base64 raster blobs wrapped in an SVG container) and was bundled into `dist/images/` on every build, but `grep -r patina_header` across `src/`, `index.html` and `Trunk.toml` shows zero references. It's dead weight. Delete it. Total image payload drops from ~20 MB to ~14 MB with no code change and no visual change. Tests ----- * `trunk build --release` -- succeeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 3 dark-mode Patina ProjectIcon SVGs were 1.4 MB each (CSS-styled vector frames wrapping embedded base64 PNG glyphs). Rasterise to 204x204 WebP (~4-5 KB each) using Edge headless to preserve the CSS class-based fills (the brand-colour rounded square border). Also drop the 3 unused light-mode variants. Total saved: ~8.5 MB on disk / wire. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 6 project images (3 hero backgrounds + 3 landing/projects tiles) were ~990 KB PNGs each (1080x900). Re-encode as WebP q82, dropping total payload from ~5.8 MB to ~300 KB (95%). No markup changes beyond the file extensions; image_button.rs and project_introduction.rs already use plain <img> tags that browsers serve transparently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
D3 v7 (~90 KB gz) and repo_graph.js were loaded eagerly via <script defer> on every page, even though only the 3 project pages ever instantiate a RepositoryGraph. Move both to on-demand: * index.html: drop both <script> tags. * repo_view.rs: ensure_graph_script() injects <script src=/repo_graph.js> exactly once, on first RepositoryGraph mount. * repo_graph.js: ensureD3() injects the D3 CDN script on first render call. The IIFE also auto-renders on load if __odpGraphData is already set, closing the race the previous design avoided by eager-loading. Subsequent project-page navigations are zero-network (publish data + call render synchronously). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drop the render-blocking Google Fonts <link> in favour of two self-hosted woff2 subsets (latin + latin-ext, ~46 KB total) that serve both 400 and 600 weights from a single variable file each. A <link rel="preload"> on the latin subset lets the browser fetch the font in parallel with the wasm/CSS bundle. Benefits: * No DNS/TLS handshakes to fonts.googleapis.com + fonts.gstatic.com on the critical path. * No third-party privacy hop on every page load. * font-display: swap renders text immediately in the system fallback and re-paints once Geist arrives -- no FOIT. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a post-build step to .github/workflows/ci.yaml that gzips the critical dist assets and fails the run if any exceeds its budget. Current sizes (left) vs budget (right): wasm 178 KB 230 KB tailwind CSS 9 KB 15 KB repo_graph CSS 1 KB 2 KB wasm-bindgen JS 8 KB 20 KB Budgets carry ~25% headroom over today's sizes so routine changes don't trip them, but a careless dependency bump or stylesheet bloat will. Update budgets alongside the change that exceeds them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Trunk fingerprints CSS/JS/wasm with content hashes in the filename, so every build emits new URLs and old assets become orphaned. That makes them safe to cache forever, but the default Cloudflare Pages cache is short. Add a public/_headers file (copied to dist root by Trunk) that: * Caches *.wasm, *.css, and the wasm-bindgen JS shim (odp-*.js) for one year, immutable. * Caches /fonts/*.woff2 and /images/* for one week (unfingerprinted but slow-changing -- a one-week stale window is acceptable). * Forces revalidation on /, /index.html, and the JSON the wasm bundle loads, so a fresh deploy is picked up immediately. * Adds light security hardening: X-Content-Type-Options, Referrer-Policy, Permissions-Policy. Only affects the Cloudflare Pages target (the GH Pages workflow ignores the file). On a returning visitor, the wasm + CSS + JS no longer hit the network at all. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lighthouse on the landing page flagged tailwind.css and
repo_graph.css as render-blocking, and the tailwind output (40 KB
raw / 8.9 KB gz) was not actually being minified by trunk in
release. Two fixes:
* Trunk.toml: set minify = "always" so Trunk runs the standalone
tailwindcss binary with --minify and also minifies the wasm-bindgen
JS shim. Tailwind drops to 24 KB / 5.3 KB gz; odp-*.js drops to
~6 KB gz.
* repo_view.rs / index.html: stop letting Trunk auto-inject
<link rel="stylesheet" href=".../repo_graph.css">. Switch the
asset to copy-file (unhashed at /repo_graph.css) and inject the
<link> tag from RepositoryGraph::Effect alongside the existing
<script src="/repo_graph.js"> injection. Now neither asset
appears on the landing page or any non-project route, so they
no longer block first paint there.
* _headers: add explicit short-cache entries for the unhashed
/repo_graph.{js,css} (the wildcard /*.css rule would otherwise
mark them as immutable, which is wrong for files without a
content hash in the name).
* ci.yaml: tighten the tailwind CSS budget to 10 KB gz.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tier 6 — Performance optimisations
Standalone follow-up to #69 (Tier 5). Branched from main, no overlap. Each task is its own commit so reviewers can read them in order.
Headline numbers (first-time visitor to the landing page)
Commits
20a9f44354427ebe4fe77b1e56cc9a90ff2171770defd7b6af495f6ab0c8ccd478298aNotes
Asset size budget) gzips the critical dist assets and fails the build if any exceeds budget. Update the budget alongside any change that legitimately exceeds it.public/_headersis consumed by Cloudflare Pages only; the GH Pages workflow ignores the file. Returning visitors get zero-network wasm/CSS/JS for a year.