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
- In
_R/minify_assets.R, includebootstrap-*.min.cssin the PurgeCSS pass (keep excluding other pre-minified vendor CSS if desired, or purge all). - 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-arrowetc.) - 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-columnsvariants - Icon font classes actually referenced from JS:
bi,bi-*
- Bootstrap JS states:
- 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. - Log before/after sizes in the build output (script already does this).
Files
_R/minify_assets.R- New
purgecss.config.cjs(or pass--safelistpatterns)
Acceptance criteria
- Deployed
bootstrap-<hash>.min.cssunder ~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.qmdcards. - 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-blockingsite_libs/bootstrap/bootstrap-<hash>.min.cssships 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 searchaa-*/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
- Remove the
@importfromtheme.scss. - Load fonts via a real
<link rel="stylesheet" href="/assets/fonts.css">in<head>— e.g. addassets/fonts.csstoformat.html.cssin_quarto.yml(it accepts a list) or aninclude-in-headerfragment. It now downloads in parallel with the Bootstrap CSS instead of after it. - Delete the unused Cormorant
@font-faceblocks fromassets/fonts.css(and the orphaned font files inassets/fonts/) — confirm first with a repo-wide grep that no page setsfont-family: Cormorantinline. - 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. Keepfont-display: swap. - Optional: drop the
.ttffallback 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.htmlinclude
Acceptance criteria
- Compiled Bootstrap CSS contains no
@import. - Network waterfall (DevTools) shows
fonts.cssand 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.scssstarts 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.cssas a parallel stylesheet viaformat.html.cssin_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 underassets/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
- Extend
_R/optimize_css_loading.R(consider renaming tooptimize_asset_loading.R) to adddeferto allsite_libsscripts in<head>that lack it, keeping document order. - Test the interactions that depend on each script; if one genuinely breaks (most likely candidate: anything in
quarto.jsthat assumeswindow.tippyexists at module-evaluation time), keep only that dependency blocking and document why. - 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 withoutdefer. - Verified working after render: search overlay (open, query, results,
f///sshortcuts), 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.Ron the Wake Forest ID site so that every classic<script src="site_libs/...">in<head>gets adeferattribute (it currently only defersbootstrap.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
- Add a homepage-only
include-in-headerpreload for the hero WebP (or generalize: teach_R/add_image_dimensions.Rto inject a preload for the first content image of each page — homepage-only is fine as a start). - Generate 480w/640w/963w WebP variants (extend
_R/convert_png_to_webp.Ror commit pre-sized assets) and emitsrcset/sizeson 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 heroassets/id-section.webpvia a homepage-only include-in-header fragment, and serve responsive sizes: generate 480w and 640w WebP variants alongside the existing 963w and emitsrcset/sizeson the hero image (either hand-written<img>inindex.qmdor by extending_R/convert_png_to_webp.R). Keepfetchpriority="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
- Add a Lighthouse CI step (or separate job) after render in
.github/workflows/quarto.yml:lhci autorun --collect.staticDistDir=wwwagainst/index.html,/people.html,/fellowship.html, one post. - 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
- Upload the HTML reports as workflow artifacts (and optionally temporary-public-storage links in the job summary).
- 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, newlighthouserc.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 runlhcilocally againstwww/.
Prompt
Add Lighthouse CI to the Wake Forest ID site’s GitHub Actions build. After
quarto renderproduceswww/, runnpx @lhci/cli autorunwithstaticDistDir=wwwagainst the homepage, people, fellowship, and one post page using mobile emulation. Createlighthouserc.jsonwith 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
- Split the
_headersrule: hashed files (/site_libs/bootstrap/bootstrap-*,*-<hash>.css) keepimmutable, max-age=31536000; all othersite_libsgetmax-age=3600, stale-while-revalidate=86400. - In
sw.js, either (a) inject a build stamp intoVERSIONpost-render (e.g. short git SHA via a tiny_Rstep), or (b) switch the static-asset handler to stale-while-revalidate (serve cache, refresh in background). Option (b) is simpler and self-healing. - 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_libsresponses 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-hashedsite_libsassetsmax-age=3600, stale-while-revalidate=86400. Insw.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-bumpVERSION. 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.Rsteps 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>inpeople-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-includedfragments/people-category-filter-fix.htmlwas deleted. - Responsive navbar brand: full title truncated (“Wake Forest Infec…”) below ~540 px.
_R/fix_navbar_aria.Rwraps the title in long/short spans;theme.scssswaps 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
- 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.
- 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.
- PR 4 (logo), PR 5 (iconify), PR 6 (LCP preload/srcset) — smaller, independent wins.
- PR 8 (caching) — anytime; it protects repeat-visit scores and prevents stale-asset breakage from the earlier PRs.