This site is a fork of the al-folio Jekyll theme, which ships a lot of optional machinery: jQuery, Bootstrap, MDB, MathJax, two icon fonts, a search palette, and a dozen per-page libraries. Most of it is useful somewhere. None of it is useful on every page, and until recently every page paid for most of it anyway.

This post is a record of what I changed, measured with the repository's own check, and what the numbers say. Every byte count below comes from a small script in the repository (it now lives at .github/scripts/page_weight.py and runs in CI) that parses a built page, finds every same-origin <script src> and <link rel="stylesheet">, and adds up the file sizes. It does not measure third-party resources or fonts, and it does not measure time. It measures the thing I can control from this repository.

The result first

Page Before (bytes) After (bytes) Change
/ 1,061,883 540,181 −49%
/about/ 980,883 464,008 −53%
/projects/ 972,083 450,382 −54%
total 3,014,849 1,454,571 −52%
Grouped bar chart of local CSS plus JS bytes per page: home 1,037 KB before versus 528 KB after, about 958 KB versus 453 KB, projects 949 KB versus 440 KB
Same-origin stylesheet and script bytes referenced by each page, baseline versus the current build, as reported by that script. Both measurements use the development build configuration, so the comparison is like for like.

Both columns are development builds (_config.yml plus _config.dev.yml), which skip the Terser minification that the production workflow applies. That makes the absolute numbers higher than what visitors download, but the before and after were measured the same way, which is what matters for a budget.

How a page gets to first paint

To know what to cut, it helps to know what the browser is waiting for. The critical rendering path is the chain of work between receiving HTML and painting pixels: parse HTML into the DOM, fetch and parse every render-blocking stylesheet into the CSSOM, combine them into the render tree, lay it out, paint. Two things lengthen it: stylesheets (always blocking) and synchronous scripts, which stop the HTML parser until they have downloaded and run.

Two Core Web Vitals name the user-visible consequences. Largest Contentful Paint is when the biggest element in the viewport finishes rendering; on this site that is a heading or the profile picture, and it cannot happen before the CSS arrives. Cumulative Layout Shift measures how much content jumps around after it first appears; late-arriving fonts and images without dimensions are the usual culprits.

Critical rendering path of a page on this site, before and after time → Before HTML main.css (full icon CSS) @import Google Fonts text fonts jQuery (blocking) Bootstrap + MDB search index (full) full icon fonts (1.17 MB) first render parser blocked by synchronous scripts After HTML main.css (subset) fonts css (<link>) text fonts icon subsets 13 KB theme.js (tiny) first render downloaded in parallel, run after the DOM jQuery defer Bootstrap + MDB defer cmdpalette.js defer search index: only on ⌘K render-blocking resource, kept small resource deferred or on-demand Widths are schematic: they show ordering and dependency, not measured durations.
Schematic of the chain to first render before and after. Widths show ordering and dependency, not measured time.

What changed

1. jQuery, Bootstrap and MDB are deferred

al-folio loads jQuery synchronously because Bootstrap and MDB depend on it. The fix is in _includes/scripts.liquid: all three tags carry defer. Deferred scripts download in parallel with parsing and execute in document order after the DOM is complete, so jQuery is still defined before Bootstrap, MDB, and every later deferred script that uses $.

The only script that must stay synchronous is no_defer.js (1,375 bytes), which registers a DOMContentLoaded handler that tags tables for dark mode before any deferred script registers its own. Its comment in the template explains why; keeping that comment next to the tag is the cheapest way to stop someone re-deferring it later.

<!-- jQuery -->
<script defer src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js" integrity="…" crossorigin="anonymous"></script>
<!-- Bootstrap & MDB scripts -->
<script defer src="/assets/js/bootstrap.bundle.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/mdbootstrap@4.20.0/js/mdb.min.js" integrity="…" crossorigin="anonymous"></script>

2. The search index is fetched on demand

The site has a command palette (ctrl/⌘ + k). Its engine, cmdpalette.js (37,480 bytes), is deferred like everything else. Its data, the build-time index of pages and posts, is not a <script> tag at all: cmdpalette.js reads the index URL from a data-index-src attribute and fetches it the first time the palette is about to open, warming it up on hover or focus of the search button and on a bare modifier-key press. A visitor who never opens the palette never downloads the index, and the index can grow with every post without touching the budget.

<script
  defer
  src="{{ '/assets/js/cmdpalette.js' | relative_url | bust_file_cache }}"
  data-index-src="{{ '/assets/js/cmdpalette-data.js' | relative_url | bust_file_cache }}"
></script>

3. Icon fonts are subset to the glyphs in use

This was the biggest single win. The theme ships Font Awesome Free and Tabler Icons in full: on disk the four woff2 files add up to 1,169,884 bytes, and the generated CSS carried a class rule for every icon (the comments in the SCSS record roughly 2,500 rules for Font Awesome and 5,700 for Tabler).

The site uses 55 Font Awesome icons and 6 Tabler icons. _sass/font-awesome/_subset.scss lists the 55 names; the icon loop in _icons.scss emits a rule only for names on that list:

// Only emit rules for icons the site uses ($fa-used-icons in _subset.scss).
@each $name, $icon in $fa-icons {
  @if index($fa-used-icons, $name) {
    .#{$fa-css-prefix}-#{$name} {
      #{$fa-icon-property}: unquote('"#{ $icon }"');
    }
  }
}

The @font-face rules then point at subset fonts built with pyftsubset from the fonttools project, with the full fonts left on disk next to them:

pyftsubset assets/webfonts/fa-solid-900.ttf --unicodes=<codepoints> \
  --flavor=woff --no-hinting --drop-tables+=GSUB,GPOS --no-layout-closure \
  --output-file=assets/webfonts/fa-solid-900-subset.woff
Font Subset file Bytes
Font Awesome solid fa-solid-900-subset.woff 3,444
Font Awesome brands fa-brands-400-subset.woff 5,644
Font Awesome regular fa-regular-400-subset.woff 2,228
Tabler Icons tabler-icons-subset.woff 2,072
total   13,388

Adding an icon means appending a name to the list and rebuilding one font; the comment at the top of _subset.scss has the exact command.

TIP

Icon fonts use Private Use Area codepoints, so font-display: swap would briefly render tofu boxes where the icons go. The subsets keep font-display: block, which is the right call when the fallback glyph is garbage rather than a different-looking letter.

4. The Google Fonts request starts with the HTML

The text fonts (Merriweather and Source Sans 3) used to be pulled in by an @import at the top of the compiled stylesheet. A CSS @import is only discovered after the importing stylesheet has downloaded and been parsed, which puts the font CSS a full round trip behind. The <link> now lives in _includes/head.liquid, preceded by preconnect hints for fonts.googleapis.com, fonts.gstatic.com and the jsDelivr CDN, so the connection setup overlaps with the HTML download.

<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,400&family=Source+Sans+3:ital,wght@0,400;0,600;0,700;1,400&display=swap"
/>

display=swap means text renders immediately in a fallback face and swaps when the web font lands; that swap is a layout-shift risk, mitigated here by choosing fallbacks (Georgia, system sans-serif) with similar metrics.

5. Page-specific scripts are gated

al-folio already loads its optional libraries (Mermaid, Chart.js, Leaflet, diff2html, pseudocode, image sliders) only when a page's front matter asks for them. This site pushes the same idea onto things the theme loaded everywhere:

  • MathJax, Masonry, medium-zoom, the reading progress bar and the back-to-top button are skipped on the home layout, which has no math, no grid and no long scroll.
  • The Academicons and Scholar Icons stylesheets load only on the About layout, the one place that renders them.
  • The sidebar table-of-contents CSS and JS load only when a page has toc: sidebar.
  • The tabs script loads only on pages that declare tabs: true.
  • The home page's own interactive scripts (home-figures.js, home-network.js, home-nav.js) are declared in the home layout, not in the shared script include.

What the home page loads now

Per-file breakdown of the current home page (540,181 bytes)
File Bytes
/assets/css/bootstrap.min.css 162,265
/assets/css/main.css 118,096
/assets/js/bootstrap.bundle.min.js 83,377
/assets/js/home-figures.js 70,248
/assets/js/cmdpalette.js 37,480
/assets/js/copy_code.js 23,329
/assets/js/home-network.js 21,838
/assets/js/theme.js 8,294
/assets/css/jekyll-pygments-themes-native.css 4,637
/assets/js/home-nav.js 4,312
/assets/js/common.js 2,035
/assets/css/jekyll-pygments-themes-github.css 1,444
/assets/js/no_defer.js 1,375
/assets/js/shortcut-key.js 772
/assets/js/jupyter_new_tab.js 679

Not counted, because they are not same-origin: jQuery 3.6.0, MDB 4.20.0 CSS and JS from jsDelivr, and the Google Fonts stylesheet.

The two Bootstrap files are now more than 45% of the home page's budget. The production workflow runs PurgeCSS over _site after the build, which removes unused selectors from bootstrap.min.css; I have not measured its output locally, so it is not in the table above, and it is the obvious next target along with a replacement for the 83 KB Bootstrap bundle.

Keeping it this way

A budget that is not enforced is a wish. The check now runs in CI on every pull request, against the same production build that gets deployed, and fails the run if any page references more local JavaScript and CSS than its budget allows:

  1. Build the site the way the deploy workflow does, then strip unused CSS:

    JEKYLL_ENV=production bundle exec jekyll build
    npx purgecss -c purgecss.config.js
    
  2. Compare every page with its budget:

    python3 .github/scripts/page_weight.py _site .github/page-weight-budget.json
    
  3. Read the verdict. The script prints one line per page and exits non-zero if any page is over:

    OK   /              237,706 / 274,000 bytes
    OK   /about/        222,497 / 256,000 bytes
    OK   /blog/         229,590 / 265,000 bytes
    
  4. When a change legitimately adds weight (a new page type, a new feature), raise that page's number in .github/page-weight-budget.json in the same pull request, so the diff shows the new budget being accepted on purpose. The budgets sit about 15% above the current pages, which leaves room for content but not for a new library.

The script is about 50 lines of Python with no dependencies. It is not Lighthouse, and it is not a substitute for looking at a real waterfall now and then. But it catches the most common regression in a theme-based site, which is a well-meaning template change that quietly puts a 300 KB library back on every page.

Update, October 2026: the second pass

The two Bootstrap files and the CDN scripts named above as the next targets are gone from the common pages. Measured on the production build after PurgeCSS, gzipped JavaScript plus CSS per page and requests to other origins:

Page JS + CSS before after Third-party requests before after
Home 70.0 KB 54.2 KB 8 (374 KB) 0
About 63.4 KB 47.3 KB 12 (438 KB) 0
Blog 66.5 KB 50.6 KB 11 (386 KB) 0
A post with math 64.4 KB 48.9 KB 26 (890 KB) 18 (455 KB, MathJax only)

What changed:

  • No jQuery, Bootstrap JavaScript or MDB on common pages. The mobile menu, the CV's contents list and scroll-spy, and the publication toggles are a few dozen lines of plain JavaScript each. The 21 MDB rules the design actually used were copied into the site's own Sass, so the MDB stylesheet is gone too. jQuery and Bootstrap still load, deferred, on a page whose content uses tooltips, popovers or tables that need them.
  • Self-hosted fonts. Merriweather and Source Sans 3 are subset into WOFF2 files per script (Latin, Latin Extended, Vietnamese, Cyrillic, Greek for Source Sans 3, and a symbols set) with unicode-range, so an English and Vietnamese page fetches only the files it needs. Fallback faces are size-adjusted to the web fonts' metrics, which keeps text from jumping when the fonts arrive.
  • MathJax only where there is math. The layout loads it only when the rendered page contains math delimiters. A later change went further: the production build now typesets the math in Node and serves MathJax's fonts from this site, so math no longer costs a post the MathJax library or any third-party request.
  • Per-page libraries only where used: zoom on posts with zoomable figures, masonry where a grid exists, citation badges where a badge is rendered.

The budget file was tightened to match, so the next library that tries to come back fails CI instead of slipping in.

Further reading