Lighthouse Performance — Pull Request Roadmap

Goal: improve Lighthouse scores, with first page load (FCP/LCP on mobile) as the priority. This plan is grounded in an audit of the actually deployed site (the live branch, served by Netlify at wakeforestid.com), not just the source tree, so the numbers below are what real visitors download today.

Each PR is self-contained, ordered by impact-to-effort, and written so it can be handed directly to a developer or coding agent. The “Prompt” block in each is agent-ready. Shared conventions match planning/website-improvement-prs.md (branch from main, one PR per item, validate with quarto render, keep accessibility wins intact).


Baseline: what first page load looks like today

Measured from origin/live:www/ (the deployed build):

Resource Size (raw) Problem
site_libs/bootstrap/bootstrap-<hash>.min.css 512 KB Render-blocking; never purged (see PR 1)
@import "/assets/fonts.css" at byte 0 of that CSS 6 KB + fonts 3-hop critical chain: HTML → 512 KB CSS → fonts.css → woff2. No font preloads
9 scripts in <head> without defer/async ~281 KB Render-blocking: autocomplete.umd.js (95 KB), tippy.umd.min.js (24 KB), fuse.min.js (23.5 KB), iconify-icon.min.js (22 KB), popper.min.js (20 KB), plus quarto-nav.js, clipboard.min.js, quarto-search.js, anchor.min.js. Only bootstrap.min.js gets defer today
assets/logo.webp (navbar, every page) 54 KB Intrinsic 1400×1399 px rendered at ~30 px height
assets/fonts.css 6 KB Declares 10 Cormorant faces that no rule in the compiled CSS uses (only “Nunito Sans” appears)
Footer <iconify-icon> (RSS/Bluesky/YouTube) Web component fetches icon data from api.iconify.design at runtime — an extra third-party origin on every page

Netlify serves Brotli, so 512 KB ≈ 70 KB on the wire — but the browser still parses all 512 KB before first paint, and the font chain and blocking scripts delay FCP/LCP regardless of compression.

Already good — don’t redo: WebP conversion with HTML rewriting (hero is 51 KB WebP with width/height and fetchpriority="high"), image dimension injection, search.json (431 KB) is lazy-loaded only when search is used, self-hosted fonts with font-display: swap, long-lived cache headers in _headers, JS/CSS minification, service worker + PWA tags, canonical links, ARIA fixes.


PR 1 — Actually purge the Bootstrap CSS (the 512 KB elephant)

Type: Build pipeline · Effort: Small–Medium · Impact: Very High Status: ✅ implemented (purgecss.config.cjs + _R/minify_assets.R). Bundle 512 KB → 165 KB; homepage lab score 64 → 75, LCP 7.0 s → 4.1 s. Bonus: the safelist also restored search/copy icons that the unsafelisted purge was stripping from bootstrap-icons.css on the live site.

Background

_R/minify_assets.R runs PurgeCSS, but filters the candidate list with css_files[!grepl("\\.min\\.css$", css_files)] — so the one file that matters, bootstrap-<hash>.min.css (512 KB, render-blocking on every page), is excluded and ships un-purged. The tiny files it does purge (bootstrap-icons.css, now 802 B) prove the mechanism works. A purged Bootstrap for a site this size is typically 50–80 KB raw (~10–15 KB Brotli): roughly a 7× cut to the largest render-blocking resource.

Scope

  1. In _R/minify_assets.R, include bootstrap-*.min.css in the PurgeCSS pass (keep excluding other pre-minified vendor CSS if desired, or purge all).
  2. Add a safelist for classes added at runtime by JS, which PurgeCSS cannot see in static HTML. At minimum:
    • Bootstrap JS states: show, showing, hiding, fade, collapse, collapsing, active, disabled, modal-*, offcanvas-*, dropdown-*, tooltip*, popover*, carousel-*, bs-* (data attrs are safe, but keep .modal-backdrop, .tooltip-arrow etc.)
    • Quarto search UI: aa-* (Algolia autocomplete), search-*, quarto-search*, mark, search-match
    • Quarto runtime: quarto-*, reader-mode*, tippy-*, anchorjs-*, aria-* attribute selectors, nav-page-*, page-columns variants
    • Icon font classes actually referenced from JS: bi, bi-*
  3. Prefer regex safelist patterns (/^aa-/, /^bi-/, /^tippy-/, /^bs-/, /^modal/, /^offcanvas/, /^carousel/, /^dropdown/, /^collaps/, /^show/, /^fade/) via a small PurgeCSS config file checked into the repo rather than a long CLI arg list.
  4. Log before/after sizes in the build output (script already does this).

Files

  • _R/minify_assets.R
  • New purgecss.config.cjs (or pass --safelist patterns)

Acceptance criteria

  • Deployed bootstrap-<hash>.min.css under ~100 KB raw.
  • Manual smoke test of every interactive surface on a local render: navbar dropdowns (mobile + desktop), search overlay (open, type, results, keyboard shortcuts), announcement banner dismiss, code-copy button, tabsets, carousel on any page using it, footer, reader mode, 404 page, a post page, the people listing filters, and groups/ideas.qmd cards.
  • No visual diff on home / people / a post / fellowship pages at mobile and desktop widths (screenshot comparison).

Prompt

In _R/minify_assets.R, PurgeCSS currently skips *.min.css, so the 512 KB render-blocking site_libs/bootstrap/bootstrap-<hash>.min.css ships un-purged. Change the pipeline so this file IS purged against all rendered HTML, using a checked-in PurgeCSS config with a safelist of runtime classes (Bootstrap JS states like show/fade/collapsing/modal/offcanvas/dropdown/ carousel, Quarto search aa-*/search-*, tippy-*, anchorjs-*, bi/ bi-*, quarto-*, reader-mode*). Keep the existing size logging. Verify by rendering locally and exercising navbar dropdowns, search overlay, announcement dismiss, code copy, tabsets, and carousels, plus screenshot diffs of key pages at mobile/desktop widths.


PR 2 — Break the font-loading chain; drop dead font faces

Type: Build pipeline / theming · Effort: Small · Impact: High Status: ✅ implemented (theme.scss, _quarto.yml css list, pruned assets/fonts.css — Cormorant and TTF fallbacks removed, ~1.9 MB of font files deleted — plus woff2 preloads injected by _R/optimize_css_loading.R). Preloaded faces are the first requests after the HTML.

Background

theme.scss line 1 is @import url('/assets/fonts.css'), which Sass passes through into the top of the compiled 512 KB Bootstrap CSS. The browser must download and parse that entire file before it even discovers fonts.css, then make another round trip before it discovers the woff2 files. Fonts therefore start downloading 2–3 network hops after the HTML. There are no <link rel="preload" as="font"> hints. Additionally, assets/fonts.css declares 10 Cormorant faces (plus TTF fallbacks) that nothing in the compiled CSS references — only “Nunito Sans” is used.

Scope

  1. Remove the @import from theme.scss.
  2. Load fonts via a real <link rel="stylesheet" href="/assets/fonts.css"> in <head> — e.g. add assets/fonts.css to format.html.css in _quarto.yml (it accepts a list) or an include-in-header fragment. It now downloads in parallel with the Bootstrap CSS instead of after it.
  3. Delete the unused Cormorant @font-face blocks from assets/fonts.css (and the orphaned font files in assets/fonts/) — confirm first with a repo-wide grep that no page sets font-family: Cormorant inline.
  4. Preload the two Nunito Sans woff2 files used above the fold (regular 400 and the bold/600-700 face used by headings/navbar): <link rel="preload" as="font" type="font/woff2" crossorigin href=...> via the same header include. Keep font-display: swap.
  5. Optional: drop the .ttf fallback sources — every browser that runs this site’s JS supports woff2.

Files

  • theme.scss, _quarto.yml, assets/fonts.css, assets/fonts/ (deletions)
  • Possibly a small fragments/font-preload.html include

Acceptance criteria

  • Compiled Bootstrap CSS contains no @import.
  • Network waterfall (DevTools) shows fonts.css and the two preloaded woff2 files starting immediately after the HTML response, not after the main CSS.
  • No FOUT regression beyond current behavior; text renders with swap.
  • Unused Cormorant faces and files removed; site typography unchanged.

Prompt

On the Wake Forest ID Quarto site, theme.scss starts with @import url('/assets/fonts.css'), which ends up at the top of the compiled render-blocking Bootstrap CSS and creates a 3-hop font chain. Remove the import; instead load /assets/fonts.css as a parallel stylesheet via format.html.css in _quarto.yml (or an include-in-header fragment), add <link rel="preload" as="font" type="font/woff2" crossorigin> for the two Nunito Sans woff2 faces used above the fold, and delete the ten unused Cormorant @font-face blocks and their files under assets/fonts/ (verify with a repo-wide grep that Cormorant is unreferenced first). Confirm in the rendered HTML that fonts start loading immediately after the document.


PR 3 — Defer the render-blocking head scripts (~281 KB)

Type: Build pipeline · Effort: Medium · Impact: High Status: ✅ implemented (generalized defer in _R/optimize_css_loading.R; no-op Bootstrap CSS preload removed). Also fixed a latent const currentUrl global collision between fragments/footer.html and quarto-search.js that had been breaking the Bluesky share links on every post (and would have broken search once deferred). With PRs 1–3 combined: homepage lab score 64 → 78, FCP 4.7 s → 2.1 s; zero render-blocking scripts remain.

Background

Quarto emits nine scripts in <head> without defer:quarto-nav.js, clipboard.min.js, autocomplete.umd.js (95 KB), fuse.min.js, quarto-search.js, popper.min.js, tippy.umd.min.js, anchor.min.js, and iconify-icon.min.js. _R/optimize_css_loading.R already adds defer to bootstrap.min.js — extend the same treatment to the rest. None of them need to run before first paint: search, tooltips, anchors, clipboard and the iconify web component are all post-load enhancements. defer preserves execution order, so popper → tippy and autocomplete/fuse → quarto-search dependencies are safe; quarto.js/tabsets.js are type="module" (already deferred) — verify they don’t race the newly deferred classic scripts.

Scope

  1. Extend _R/optimize_css_loading.R (consider renaming to optimize_asset_loading.R) to add defer to all site_libs scripts in <head> that lack it, keeping document order.
  2. Test the interactions that depend on each script; if one genuinely breaks (most likely candidate: anything in quarto.js that assumes window.tippy exists at module-evaluation time), keep only that dependency blocking and document why.
  3. While in there: the existing “preload + stylesheet link” pair emitted for the Bootstrap CSS is a no-op (the preload sits immediately before the stylesheet tag) — remove it to simplify, or leave with a comment.

Files

  • _R/optimize_css_loading.R, _quarto.yml (if renamed)

Acceptance criteria

  • Rendered <head> has zero classic scripts without defer.
  • Verified working after render: search overlay (open, query, results, f///s shortcuts), tooltips/hover-cites on a post with footnotes, anchor links on headings, code-copy, navbar collapse on mobile, tabsets, footer icons render.
  • Lighthouse “Eliminate render-blocking resources” no longer lists these scripts.

Prompt

Extend _R/optimize_css_loading.R on the Wake Forest ID site so that every classic <script src="site_libs/..."> in <head> gets a defer attribute (it currently only defers bootstrap.min.js). Defer preserves order, so popper→tippy and autocomplete/fuse→quarto-search chains stay intact. After rendering, manually verify search overlay + keyboard shortcuts, tooltips on a post, heading anchors, code copy, mobile navbar collapse, tabsets, and footer icons. If a specific script breaks when deferred, leave only it blocking with a comment explaining the dependency.


PR 6 — Preload the homepage LCP image + responsive sizes

Type: Build pipeline / content · Effort: Small–Medium · Impact: Medium Status: ✅ implemented. (Note: the plain LCP preload already existed via _R/add_image_dimensions.R — this audit’s baseline table missed it.) Added 480/800/963w variants + srcset/sizes on the hero, and taught the preload injector imagesrcset/imagesizes so mobile preloads the same variant it renders. Phones now fetch 16 KB instead of 49 KB.

Background

The homepage hero (assets/id-section.webp, 51 KB, 963×605) is already in good shape: WebP, explicit dimensions, fetchpriority="high". Two refinements remain: (1) the browser only discovers it after HTML parsing — a <link rel="preload" as="image"> on the homepage removes ~1 RTT from LCP; (2) a single 963 px file is served to all viewports — a 480/640 px variant saves ~25–35 KB on phones, where Lighthouse scores are computed.

Scope

  1. Add a homepage-only include-in-header preload for the hero WebP (or generalize: teach _R/add_image_dimensions.R to inject a preload for the first content image of each page — homepage-only is fine as a start).
  2. Generate 480w/640w/963w WebP variants (extend _R/convert_png_to_webp.R or commit pre-sized assets) and emit srcset/sizes on the hero <img>.

Files

  • index.qmd (front-matter include), fragments/ (new preload snippet), optionally _R/convert_png_to_webp.R

Acceptance criteria

  • Homepage waterfall shows the hero image starting with the HTML/CSS, before parser discovery.
  • Phones (≤480 px viewport, DevTools) download the small variant.
  • No regression in fetchpriority/dimensions attributes from the existing post-render steps.

Prompt

On the Wake Forest ID homepage, add a <link rel="preload" as="image"> for the hero assets/id-section.webp via a homepage-only include-in-header fragment, and serve responsive sizes: generate 480w and 640w WebP variants alongside the existing 963w and emit srcset/sizes on the hero image (either hand-written <img> in index.qmd or by extending _R/convert_png_to_webp.R). Keep fetchpriority="high" and width/height intact. Verify with DevTools that a 400 px viewport fetches the small file.


PR 7 — Lighthouse CI guardrail in GitHub Actions

Type: CI / measurement · Effort: Medium · Impact: High (durability) Status: ✅ implemented (lighthouserc.json, workflow audit step before publish, warn-only budgets, job-summary table + report artifacts). Baseline numbers recorded in dev/lighthouse-ci.md.

Background

Every optimization above can silently regress (a Quarto upgrade re-adds a blocking script; a new page breaks the purge safelist; someone commits a 3 MB hero). There is currently no automated measurement. The build already runs in GitHub Actions with Node available, and the rendered site is a static www/ directory — ideal for @lhci/cli with its built-in static server. This also gives the project its first reproducible baseline numbers, since third-party fetchers (and this audit environment) are blocked from hitting the production site directly.

Scope

  1. Add a Lighthouse CI step (or separate job) after render in .github/workflows/quarto.yml: lhci autorun --collect.staticDistDir=www against /index.html, /people.html, /fellowship.html, one post.
  2. Set assertions/budgets that encode this plan’s targets, e.g.:
    • performance score ≥ 0.90 (mobile emulation, warn at first, error later)
    • total render-blocking bytes < 120 KB
    • LCP < 2.5 s, CLS < 0.1, TBT < 200 ms (lab)
    • document + CSS + JS transfer budgets
  3. Upload the HTML reports as workflow artifacts (and optionally temporary-public-storage links in the job summary).
  4. Run on PRs (build-only, no deploy) so regressions surface before merge — note the render needs the R toolchain, so reuse the existing bootstrap steps or gate the PR job to changed paths.

Files

  • .github/workflows/quarto.yml, new lighthouserc.json

Acceptance criteria

  • CI publishes Lighthouse reports per run; budgets fail the job when exceeded (after an initial warn-only bedding-in period).
  • README or planning/ note documenting how to run lhci locally against www/.

Prompt

Add Lighthouse CI to the Wake Forest ID site’s GitHub Actions build. After quarto render produces www/, run npx @lhci/cli autorun with staticDistDir=www against the homepage, people, fellowship, and one post page using mobile emulation. Create lighthouserc.json with assertions: performance ≥ 0.9 (warn initially), render-blocking bytes < 120 KB, LCP < 2.5 s, CLS < 0.1, TBT < 200 ms. Upload HTML reports as artifacts and summarize scores in the job summary. Document local usage in the repo.


PR 8 — Cache-correctness hardening (protect the wins)

Type: Infrastructure · Effort: Small · Impact: Medium (repeat visits + safety) Status: ✅ implemented (_headers: site_libs now max-age=86400, stale-while-revalidate=604800 — Netlify rules can’t single out the hashed bootstrap file; sw.js: static assets switched to stale-while-revalidate, VERSION bumped to v2).

Background

Two latent staleness traps will eventually undo performance work or break the site for returning visitors: 1. _headers marks all of /site_libs/* as immutable, max-age=1y, but only the Bootstrap CSS filename is content-hashed. Files like quarto-nav.js, quarto.js, autocomplete.umd.js keep stable names and change across Quarto upgrades — returning visitors can hold year-old JS against new HTML. 2. sw.js uses cache-first for all CSS/JS/images with a hard-coded VERSION = "v1" and no background revalidation — cached assets are never refreshed until someone remembers to bump the version.

Scope

  1. Split the _headers rule: hashed files (/site_libs/bootstrap/bootstrap-*, *-<hash>.css) keep immutable, max-age=31536000; all other site_libs get max-age=3600, stale-while-revalidate=86400.
  2. In sw.js, either (a) inject a build stamp into VERSION post-render (e.g. short git SHA via a tiny _R step), or (b) switch the static-asset handler to stale-while-revalidate (serve cache, refresh in background). Option (b) is simpler and self-healing.
  3. Keep /assets/fonts/* immutable (their filenames are content-hashed).

Files

  • _headers, sw.js, optionally a small _R/stamp_sw_version.R

Acceptance criteria

  • Non-hashed site_libs responses carry the revalidating Cache-Control.
  • After deploying a change to a cached asset, a repeat visitor gets the new version within one navigation (SWR) without a manual SW version bump.

Prompt

Harden caching on the Wake Forest ID site. In _headers, keep 1-year immutable caching only for content-hashed files (Bootstrap CSS, fonts) and give non-hashed site_libs assets max-age=3600, stale-while-revalidate=86400. In sw.js, change the static-asset strategy from pure cache-first to stale-while-revalidate so cached CSS/JS refresh in the background, removing the need to hand-bump VERSION. Verify with two successive loads after changing an asset.


Follow-up round (implemented after PRs 1–8)

  • Listing pages (people/research/addiction): Quarto’s listing template emits a render-blocking cdnjs ES6 polyfill and a deferred ~1 MB MathJax bundle. Both stripped post-render (_R/optimize_css_loading.R steps 5–6); listing pages now make zero third-party requests. People page: perf 65 → 87, FCP 4.1 s → 1.8 s, LCP 6.9 s → 2.9 s (median of 3).
  • People category filter (functional bug, pre-existing): class="list" sat on the <table> in people-table.ejs, so list.js saw thead/tbody as its only items and category clicks filtered to nothing. Moved to <tbody>; stock Quarto filtering works, and the never-included fragments/people-category-filter-fix.html was deleted.
  • Responsive navbar brand: full title truncated (“Wake Forest Infec…”) below ~540 px. _R/fix_navbar_aria.R wraps the title in long/short spans; theme.scss swaps in a compact WF-ID mark below 540 px, keeping the full name visually hidden for assistive tech.

Stretch ideas (only after PRs 1–3 land and are measured)

  • Critical CSS inlining: after purging, the main CSS may be small enough (~10–15 KB Brotli) that inlining is unnecessary complexity. Measure first.
  • Trim Nunito Sans weights: if only 400/700 render above the fold, the remaining declared weights can lazy-load or be dropped.
  • Drop tippy/popper entirely if hover citations/tooltips are unused site-wide (they’re mostly used on posts with footnotes — check).
  • Announcement banner CLS: the dismissable banner is removed by JS after load for visitors who dismissed it, shifting content up. If CLS shows up in field data, render-block it with a tiny inline script that checks localStorage before first paint.

Suggested sequencing

  1. PR 7 first or in parallel (Lighthouse CI) — establish the baseline number so every other PR’s impact is measurable, even though it’s listed as a guardrail.
  2. PR 1 (purge), PR 2 (fonts), PR 3 (defer) — the first-load core; land separately so regressions bisect cleanly. Expected combined effect: render-blocking payload drops from ~793 KB raw (512 KB CSS + 281 KB JS) to well under 100 KB raw, and font/LCP requests start immediately after the HTML. This is where the mobile score moves most.
  3. PR 4 (logo), PR 5 (iconify), PR 6 (LCP preload/srcset) — smaller, independent wins.
  4. PR 8 (caching) — anytime; it protects repeat-visit scores and prevents stale-asset breakage from the earlier PRs.