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% |
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.
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: swapwould briefly render tofu boxes where the icons go. The subsets keepfont-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:
-
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 -
Compare every page with its budget:
python3 .github/scripts/page_weight.py _site .github/page-weight-budget.json -
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 -
When a change legitimately adds weight (a new page type, a new feature), raise that page's number in
.github/page-weight-budget.jsonin 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
- Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) on web.dev: what the metrics measure and what moves them.
- Critical rendering path: the model behind the diagram above.
- Best practices for fonts, including
preconnect,font-displayand why@importis slow. - MDN on the
<script>element (deferversusasync) and onrel="preconnect". - fonttools, the project that provides
pyftsubset, and PurgeCSS, which the deploy workflow runs after the build.