Intro
I almost replaced this blog’s theme. I liked the look of another Hugo theme. Before I migrated 25 posts, I wanted to know what was actually wrong with the current one. A Lighthouse report gave a clearer answer than my taste did: the theme was fine, but the way it shipped assets was not.
This post covers the Lighthouse findings and the fixes. It also covers why production stayed slow after I merged the fixes, and why that led me from Netlify to Cloudflare Workers. I did the work in one session with Claude Code. It ran the audits and proposed the fixes, and I made the calls on tradeoffs.
TL;DR: Mobile performance was 69. The causes were a render-blocking comment script whose domain no longer resolves, a preloader that hid the page until every script finished, and a lazy-loaded hero image. There were also about 410 KB of CSS and JS inlined into every page, and 322 KB of Font Awesome for six icons. The fixes took local scores to 94 (home) and 96 (post). Production barely moved. The site sat behind the Cloudflare proxy in front of Netlify, with uncached HTML and scripts that Cloudflare injected. I moved hosting to Cloudflare Workers static assets, built from GitHub by Workers Builds.
Update: after the cutover, I vendored the Hugo modules (copied them into the repo) to cut the build time. I fixed relative URLs from the Netlify days in the sitemap, RSS feed, and share previews. I removed Netlify entirely and self-hosted the font. In production, the post now scores 97 for performance and 100 for accessibility, best practices, and SEO on desktop. The home page scores 83 to 85 on mobile. See After the Cutover and Final Results.
The Figure
The bench measures one request for the home page. The lit baton leaves the start gate on the left. When it comes back, the stopwatch reads the time to first byte. On the back lane, it passes the Cloudflare proxy tower, which does not cache the HTML. It runs on to the Netlify edge at the far end and returns the same way. On the front lane, the same Cloudflare edge serves the page, and the baton turns around there. The crates on the back sled are the page weight that the fixes removed. They drop into the bin at stage 2. Click the figure or press 1, 2 or 3 to select a stage. Press Space to run it again. Runs play at one fifth of real time, and every number comes from the tables below. I generated the figure with the anatomy agent skill. It builds interactive isometric SVG figures from a description of the system.
The Situation
- Hugo site on a vendored copy of the
hydrogen-hugotheme (Bootstrap 5.2, SCSS compiled by Hugo). - Hosted on Netlify, built from
main, with the domain on Cloudflare DNS, proxied (orange cloud). - Third parties: Google Analytics, counter.dev, Valine comments, Google Fonts (Questrial through the WebFont loader), Font Awesome from cdnjs.
- Measured with Lighthouse 12 (mobile, simulated throttling) from my laptop, and PageSpeed Insights for a second opinion.
The baseline on the live site:
| Page | Performance | Accessibility | Best practices | FCP | LCP |
|---|---|---|---|---|---|
| Home | 69 | 98 | 82 | 4.6 s | 5.3 s |
| Post | 73 | 88 | 79 | 3.6 s | 4.6 s |
PageSpeed Insights agreed: performance 71, LCP 5.2 s, 573 KiB transferred. It flagged one script as render-blocking for 724 ms.
Issue 1: A Dead Comment Service Blocking Every Post
Symptom
Every post logged net::ERR_NAME_NOT_RESOLVED in the console, and Lighthouse listed https://cdn1.lncld.net/static/js/3.0.4/av-min.js under render-blocking requests.
Cause
Valine comments load the LeanCloud SDK from cdn1.lncld.net as a plain synchronous <script> in the page body. That hostname no longer resolves. The browser still must try, and it cannot render past a synchronous script until the request fails. The comments themselves were broken for who knows how long.
Fix
# exampleSite/config/_default/params.toml
[valine_comment]
enable = false
Issue 2: A Preloader That Waited for Everything
Symptom
FCP was above 4 s, but the HTML arrived well before that.
Cause
The theme shows a full-screen preloader and hides it on window.load. load waits for every image, font, and script. That includes the dead LeanCloud request and Google Analytics. The content was ready, but covered.
Fix
preloader = false in params.toml. The page now paints as soon as the HTML and CSS arrive.
Issue 3: The LCP Image Was Lazy-Loaded
Symptom
Lighthouse: “Largest Contentful Paint image was lazily loaded”.
Cause
A shared image partial renders the hero image on the home page, and that partial defaults to loading="lazy". If the largest image above the fold loads lazily, the browser delays the exact element that LCP measures.
Fix
{{ partial "image.html" (dict "Src" .Params.image "Class" "intro-image mb-5 rounded-circle" "Alt" .Params.title "Size" "374x" "Loading" "eager") }}
Issue 4: 410 KB of CSS and JS Inlined into Every Page
Symptom
Each HTML page was about 445 KB uncompressed (99 KB gzipped). The posts themselves are 10 to 20 KB of text.
Cause
The theme inlined everything: 284 KB of CSS (Bootstrap plus Font Awesome) in a <style> tag, and 56 KB of Bootstrap JS plus 71 KB of other scripts in <script> tags. Inlining saves a request on the first page. But the browser cannot cache any of it, so every page view downloads it again.
Fix
Serve them as fingerprinted files and cache them forever:
{{ $styles := $styles | resources.Concat "/css/style.css" | minify | fingerprint "sha512"}}
<link rel="stylesheet" href="{{ $styles.RelPermalink }}" integrity="{{ $styles.Data.Integrity }}" crossorigin="anonymous">
# netlify.toml
[[headers]]
for = "/css/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Two things broke along the way:
hugo --minifyminifies only HTML output, including inline blocks. When the CSS moved to its own file, it came out unminified (344 KB). So the pipeline needed an explicit| minify.- An inline
WebFont.load(...)call ran immediately. ButWebFontnow came from a deferred file, so the call threwReferenceError: WebFont is not defined. I wrapped it inDOMContentLoaded, and that fixed the order, because deferred scripts run before that event.
HTML per page dropped from 445 KB to about 29 KB.
Issue 5: A 1.6 KB Library Blocking Render for 724 ms
Symptom
PageSpeed Insights flagged js.cookie.min.js from cdnjs as render-blocking for 724 ms.
Cause
The cookie banner loaded js-cookie as a synchronous script only to read and write one cookie.
Fix
Native document.cookie, no library:
if (!document.cookie.split('; ').includes('cookie-box=true')) {
cookieBox.classList.remove('cookie-box-hide');
cookieButton.onclick = function () {
document.cookie = 'cookie-box=true; max-age=' + (2 * 86400) + '; path=/; SameSite=Lax';
cookieBox.classList.add('cookie-box-hide');
};
}
Issue 6: 322 KB of Font Awesome for Six Icons
Symptom
Two font files came from cdnjs (151 KB and 111 KB), plus 62 KB of Font Awesome CSS. Lighthouse’s “Reduce unused CSS” pointed directly at it (94% unused).
Cause
The built site used exactly six icons: search, GitHub, LinkedIn, Twitter, Instagram, and Medium. One .notices::before rule also referenced the icon font, but no post uses notices.
Fix
I downloaded the six SVGs from the official @fortawesome/fontawesome-free package (CC BY 4.0, attribution kept in each file) into assets/icons/. Then I added a partial that inlines them. It accepts the existing configuration values like fab fa-github, so params.toml did not change:
{{ $name := replaceRE `.*fa-` "" . }}
{{ with resources.Get (printf "icons/%s.svg" $name) }}
{{ replace .Content "<svg " `<svg class="icon" aria-hidden="true" focusable="false" ` | safeHTML }}
{{ end }}
The six icons are 5.8 KB in total. The stylesheet went from 284 KB to 172 KB (27 KB gzipped), and the only font left on the page is Questrial. While I was there, I found that the social links had a literal title="title". So screen readers announced every one of them the same way. They now carry the network name.
Issue 7: Accessibility Failures I Had Never Noticed
Symptom
Accessibility 88 on posts: low contrast, a skipped heading level, and a button whose visible text did not match its accessible name.
Cause and fix
- Heading order: post cards used
<h3>directly under the page<h1>. I changed them to<h2 class="h3">, so they look the same. - Menu button: visible text
MENU,aria-label="Toggle navigation". I changed the label toMenu. - Inline code: Bootstrap’s default
#d63384on my dark background is 3.31:1, below the WCAG AA minimum of 4.5:1. I set--bs-code-color: #ff79c6. The first attempt did nothing. The site has its own emptycustom.scssthat shadows the theme’s file, so the override must live inexampleSite/assets/scss/custom.scss. - Syntax highlighting: two
onedarkcolors fail on the code block background#25303a:
| Color | Used for | Before | After |
|---|---|---|---|
#e06c75 to #e37981 | names, YAML keys, tags | 4.21:1 | 4.68:1 |
#7f848e to #9398a0 | comments | 3.58:1 | 4.63:1 |
Hugo writes highlight colors inline by default, so CSS cannot override them. I switched Chroma to class-based output. I ship the generated stylesheet with only those two colors changed:
[markup.highlight]
noClasses = false # colors come from assets/scss/_syntax.scss
hugo gen chromastyles --style=onedark > exampleSite/assets/scss/_syntax.scss
# then replace #e06c75 and #7f848e
Dead End: A Local Comparison That Measured the Wrong Site
My first before/after run showed the fixed build as worse, with 12 console errors. The build used the production baseURL, so the local pages loaded their CSS and JS from https://franzramadhan.dev. The browser blocked them as cross-origin requests (the tags carry crossorigin="anonymous"). I rebuilt both versions with --baseURL http://localhost:<port>/, and that gave a fair comparison.
Results (Local)
Lighthouse 12, mobile, served by Python’s http.server (no gzip, no HTTP/2):
| Page | Metric | Before | After |
|---|---|---|---|
| Home | Performance | 89 | 94 |
| Post | Performance | 82 | 96 |
| Post | Accessibility | 88 | 100 |
| Post | Best practices | 79 | 100 |
I kept Google Analytics. It is the largest remaining third party (180 KB, about 220 ms of main-thread work), and I accepted that tradeoff.
Issue 8: Production Stayed Slow After the Merge
Symptom
After the merge, the live home page scored 58 and 69 on two runs, with LCP at 5.1 s and best practices stuck at 82. The fixes were live (the CSS was served with immutable, and the transfer size was down to 344 KB). So something outside the repo cost the time.
Cause
The LCP breakdown said most of the time was before and around the first byte:
| LCP part | Time |
|---|---|
| Time to first byte | 1,097 ms |
| Resource load delay | 864 ms |
| Resource load time | 3,074 ms |
| Element render delay | 89 ms |
The response headers explained it:
curl -sI https://franzramadhan.dev/ | grep -iE 'server|cf-cache-status|x-nf-request-id'
# server: cloudflare
# x-nf-request-id: 01M4ZXG06GTF0ET0MDWGJXB8CT
# cf-cache-status: DYNAMIC
Every request went through two CDNs: Cloudflare’s proxy, then Netlify’s edge. Cloudflare does not cache HTML by default (DYNAMIC), so each page view went all the way to Netlify. TTFB from my laptop was between 0.5 and 1.2 s. Also, the proxy injected its own scripts: the bot detection script (/cdn-cgi/challenge-platform/.../main.js, a 97 ms long task), the Web Analytics beacon, email obfuscation, and speculation rules. The “deprecated API” warnings capped best practices at 82 (StorageType.persistent, Fledge). They came from those scripts and Google Analytics, not from my code.
The Hosting Decision
Options
| Option | TTFB | Injected scripts | Keeps Cloudflare WAF | Effort |
|---|---|---|---|---|
| A. DNS only (grey cloud), Netlify serves directly | Fixed | Gone | No | 5 min |
| B. Keep proxy, cache HTML, purge on every deploy | Fixed while cache is warm | Off if disabled | Yes | Medium, and a missed purge serves a stale site |
| C. Keep proxy, only disable the extras | Not fixed | Gone | Yes | 5 min |
| D. Move hosting to Cloudflare | Fixed (one CDN) | Optional | Yes | About an hour, plus testing |
| E. Preload the hero image only | Not fixed | No change | Yes | Small |
Assessed impact and tradeoffs
I first leaned toward A. Two things changed my mind:
- Netlify’s free plan is now credit-based. It has 300 credits a month with a hard cap, and every production deploy costs 15. That is about 20 deploys a month, before bandwidth. My
mainbranch had 20 deploys between October 12 and 15 alone, because I publish this homelab series one merge at a time. - My domain, DNS, and email routing already live on Cloudflare. With hosting there, the path has one CDN. I also keep the proxy features that option A removes.
On the Cloudflare side, Pages was the obvious name. But Cloudflare now recommends Workers static assets for new sites, and new features go to Workers. A static site needs no Worker code. You point it at the build output. Cost is not a concern at this size. Static asset requests are free, and the free tier has 3,000 build minutes a month (a build here takes about a minute). Every branch gets a preview URL.
What I give up: Netlify-specific features, mainly Netlify Agent Runners. I set those up only a few days earlier.
The Migration
Setup
The whole hosting configuration is in the repo. wrangler.jsonc at the root:
{
"name": "franzramadhan-dev",
"compatibility_date": "2026-10-15",
"build": {
"command": "bash scripts/build.sh"
},
"assets": {
"directory": "./exampleSite/public",
"not_found_handling": "404-page"
},
"preview_urls": true,
"previews": {}
}
The headers from netlify.toml moved to exampleSite/static/_headers. Workers static assets reads that file and does not serve it publicly:
/*
X-Frame-Options: DENY
Referrer-Policy: same-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
/css/*
Cache-Control: public, max-age=31536000, immutable
Builds run in Workers Builds connected to the GitHub repo, with no GitHub Actions. Pushes to main run npx wrangler deploy, and other branches run npx wrangler preview. Both run build.command first.
Issue 9: The Token in My Shell Belonged to Another Account
The first API call showed that the token can see exactly one account. It was a work account, not the one that owns franzramadhan.dev. The zone lookup returned nothing. I created nothing there. I made a separate personal token and exported it under different names (CF_PERSONAL_API_TOKEN, CF_PERSONAL_ACCOUNT_ID). I pass them to wrangler per command, so the work token stays the default for work.
The token itself had two surprises:
- The Workers Scripts permission now shows as Legacy. In September 2026, Cloudflare introduced Workers roles (Metadata Read-Only, Content Read-Only, Editor, Admin). Only Admin at the product scope can create a new Worker.
- The Builds API docs ask for a “Workers Builds Configuration” permission. The token screen no longer offers it, and Workers Admin returns
Authentication erroron that API. So build settings live in the dashboard. The GitHub App install lives there too, because the API cannot do it anyway.
Issue 10: Four Build Failures in a Row
Each Workers Builds failure taught me something about the build image:
Found a .tool-versions file in repository root. Installing dependencies.thenFailed. The file was from my asdf days (golang 1.21.4,direnv 2.33.0). Next to it was an.envrcwith onlyuse asdf. I use mise now. I deleted both files.Your Wrangler configuration is missing a previews block.wrangler preview(open beta) needs"previews": {}even if it is empty.you need the extended version to build SCSS/SASS with transpiler set to 'libsass'.HUGO_VERSION=0.168.0installed the standard edition.extended_0.168.0did not help.- The fix that worked: stop relying on the image. I followed Hugo’s own Cloudflare deployment guide.
scripts/build.shinstalls pinned tools itself, and I added a sha256 check for each download:
DART_SASS_VERSION="1.105.1"
GO_VERSION="1.27.2"
HUGO_VERSION="0.168.0"
fetch() {
local url="$1" sha256="$2" dest="$3" file
file="$tmp/$(basename "$url")"
curl -fsSL -o "$file" "$url"
echo "$sha256 $file" | sha256sum -c --quiet -
mkdir -p "$dest"
tar -xzf "$file" -C "$dest"
}
With SKIP_DEPENDENCY_INSTALL=true in the build variables, the image skips its own tool install. So no .tool-versions or HUGO_VERSION can cause a problem. Locally, the same versions come from mise.toml.
Moving from LibSass to Dart Sass
LibSass is deprecated since Hugo 0.153 and ships only in Hugo extended. So I switched the theme’s stylesheet to Dart Sass, which works with standard Hugo:
toCSS (dict "transpiler" "dartsass" "silenceDeprecations" (slice "import" "global-builtin" "color-functions" "abs-percent" "function-units" "if-function" "slash-div"))
Bootstrap 5.2 triggers more than 347 deprecation warnings under Dart Sass 1.105. That is enough to bury a real error in the build log, so I added the silence list. To make sure that nothing changed visually, I took screenshots of the home page, a post, the contact page, and a category page from both builds. Then I compared them: 0 differing pixels on all four.
Two smaller things
- Theme folder name. The old build used
--themesDir ../... That works only when the checkout folder has the same name as the theme. My local folder isfranzramadhan.dev, and Hugo reads a dotted name as a module path.build.shnow symlinks the repo root ashydrogen-hugoin a temp folder, so the build no longer depends on the folder name. - No preview URL comment on the PR. Workers Builds reports a check run but did not comment on the PR, even with pull request write access on the GitHub App. The app description still says that comments are for Cloudflare Pages only. Branch preview URLs are predictable, so I use them directly:
feat/cloudflare-workersbecomeshttps://feat-cloudflare-workers-franzramadhan-dev.<subdomain>.workers.dev.
Results so far
I compared the Worker’s workers.dev URL with the current production setup, from my laptop:
| Workers | Cloudflare proxy to Netlify | |
|---|---|---|
| Performance (2 runs) | 71, 79 | 72, 75 |
| Best practices | 100 | 82 |
| TTFB (4 requests) | 0.23 to 0.75 s | 0.69 to 1.07 s |
Best practices is fixed and TTFB is lower, but the performance score barely moved. Two caveats apply. First, some of my requests go through Paris (cf-ray ended in CDG), so my absolute numbers are noisy. Second, the remaining cost is the render path itself (the stylesheet, Questrial through the WebFont loader, and the hero image). That is the same on both hosts. Issues 15 and 16 below deal with that part.
The cutover
You cannot attach a Worker Custom Domain while another DNS record exists for the same name. So the order was important:
- Stop Netlify builds, so its last good deploy keeps serving while the PR is open.
- Add the domain to
wrangler.jsonc. Setworkers_devoff, so the site is not also served on a second hostname:
"routes": [{ "pattern": "franzramadhan.dev", "custom_domain": true }],
"workers_dev": false,
- Delete the apex
CNAMEtoapex-loadbalancer.netlify.com, then runwrangler deployimmediately. Cloudflare created the DNS record and certificate, and the site was down for less than a minute. wwwused to redirect through Netlify. It is now a proxiedAAAA 100::placeholder plus a Redirect Rule (https://www.*tohttps://${1}, 301, query string kept). For about a minute after I deployed the rule,wwwreturned 522. The request reached the placeholder origin, because the rule was not on every edge yet.
I did not touch the email records.
After the Cutover
The domain worked, but the next few merges showed six more problems.
Issue 11: The Production Build Still Had the Old Variables
Symptom
The production build for the migration merge sat at Initializing build environment... for almost four minutes, and its build settings still listed HUGO_VERSION.
Cause
Workers Builds keeps separate variables for the Production and Previews Base tabs, and I updated only Previews Base. A build also keeps the variables from the time it was created. So a later fix to the tab did not change builds that were already queued.
Fix
Set SKIP_DEPENDENCY_INSTALL=true and remove HUGO_VERSION in the Production tab, cancel the queued builds, and push an empty commit to main to start a fresh one.
Issue 12: Netlify Broke After the Dart Sass Switch
Symptom
Netlify’s deploy preview for the migration PR failed.
Cause
Dart Sass needs a sass binary, and Netlify’s build had Hugo extended 0.167.0 without it. I expected that, because Netlify was on its way out. The surprise was that my first local reproduction passed. Hugo reused CSS cached in exampleSite/resources/_gen from earlier Dart Sass builds. It failed like Netlify only with an empty resource cache:
HUGO_RESOURCEDIR="$(mktemp -d)" hugo --minify --gc # no sass on PATH
# ERROR ... TOCSS: failed to transform "style.scss" ...
The Linux container test that I used to validate build.sh copied that same cache, so it proved less than I thought. The real proof was the first Workers build from a fresh clone.
Fix
I stopped Netlify builds (Project configuration > Developer settings > Continuous deployment > Build settings > Configure > Stopped builds), so Netlify kept serving its last good deploy until the cutover. Later I removed netlify.toml, plus exampleSite/netlify.toml, vercel.json, and vercel-build.sh. Those were leftovers from the theme template (Hugo 0.98, January 2024). Then I deleted the Netlify project.
Issue 13: Five Minutes per Build
Symptom
A preview build took 5m44s:
| Phase | Time | What runs |
|---|---|---|
| Initializing | 3m50s | Cloudflare starts the build machine, before the clone |
| Cloning | 5s | git clone |
| Installing | 336 ms | skipped by SKIP_DEPENDENCY_INSTALL |
| Deploying | 1m49s | build.sh, npx wrangler, upload |
Cause
Initializing is outside the repo. Earlier the same day, it took 4 to 6 seconds. Cloudflare had an incident open that evening, and the free plan runs one build at a time. Deploying was my part. build.sh downloaded Go (about 70 MB) only so that Hugo can fetch its modules (about 15 s). npx downloaded wrangler (about 8 s).
Fix
Vendor the modules (copy them into the repo), so the build needs neither Go nor a module download:
hugo mod vendor # writes exampleSite/_vendor: 3.0 MB, 280 files
build.sh no longer installs Go, and it now downloads Dart Sass and Hugo in parallel. Locally, a clean build takes about a second, with only sass and hugo on the PATH and an empty Hugo cache.
The cost is that _vendor must follow every module change. A local pre-commit hook runs hugo mod vendor when go.mod, go.sum, or module.toml is staged. It stops the commit until the refreshed _vendor is staged too:
repos:
- repo: local
hooks:
- id: hugo-mod-vendor
name: re-vendor Hugo modules
entry: scripts/vendor-modules.sh
language: script
files: ^exampleSite/(go\.mod|go\.sum|config/_default/module\.toml)$
pass_filenames: false
You can skip hooks, so build.sh also compares _vendor/modules.txt with module.toml and go.mod. It uses sed and awk, and fails with _vendor has <module> <version>, go.mod wants <version> (run: hugo mod vendor). A test with a real module update found two bugs in my first version. First, the hook counted changes that were already staged, so it never passed. Second, hugo mod get hands trailing flags to go get, so it silently ignored --themesDir. The hook now looks only at unstaged and untracked files. It sets the theme folder with HUGO_THEMESDIR instead.
Issue 14: Relative URLs in the Sitemap, RSS, and Share Previews
Symptom
On the live site:
<meta property="og:image" content="/images/posts/aws-gcp-oidc-federation.png">
<meta property="og:url" content="/blog/02-gcp-to-aws-short-lived-credential/">
<loc>/blog/</loc> # sitemap.xml
<link>/blog/26-lighthouse-and-cloudflare-workers/</link> # index.xml
Cause
Netlify built with HUGO_BASEURL="/", and I kept baseURL "/" so that previews load their own CSS. Every absolute URL that Hugo generates became a path. Sitemaps require absolute URLs, and feed readers cannot follow relative links. Share previews (LinkedIn, X) need an absolute image URL. There was also no canonical tag at all.
Fix
Workers Builds sets WORKERS_CI_BRANCH. So production keeps baseURL from config.toml, and only previews and local builds use /:
baseurl_flag=()
if [[ "${WORKERS_CI_BRANCH:-}" != "main" ]]; then
baseurl_flag=(--baseURL "${HUGO_BASEURL:-/}")
fi
hugo build --gc --minify --themesDir "$themes" ${baseurl_flag[@]+"${baseurl_flag[@]}"}
The odd-looking expansion is for macOS’s bash 3.2. That version treats an empty array as unbound under set -u. Every page also gets <link rel="canonical" href="{{ .Permalink }}"> unless its front matter sets one. Asset links come from RelPermalink, so they stay relative in both modes.
Issue 15: The Font Waited for JavaScript
Symptom
With the hosting fixed, a fresh mobile Lighthouse run of the live home page scored 74 to 76. Server response was down to 90 to 110 ms. But LCP was still about 5 s, and most of it was render delay (3.4 to 4.0 s). The hero image finished its download in under a second and then waited for the paint.
Cause
The WebFont loader, a script in the deferred bundle, loaded Questrial. The font request started only after that script ran. Then came a new connection to fonts.googleapis.com, a stylesheet, and the font file from fonts.gstatic.com. In the trace, the Google Fonts stylesheet started at 322 ms, after everything else on the page arrived. First paint waited for that chain.
Fix
Self-host the font. Questrial uses the SIL OFL license, and that license allows it. So I downloaded the same woff2 subsets that Google serves. I ship them with the site, together with OFL.txt:
exampleSite/static/fonts/questrial-v19-latin.woff2 14 KB
exampleSite/static/fonts/questrial-v19-latin-ext.woff2 22 KB, only fetched for latin-ext characters
@font-face {
font-family: 'Questrial';
font-display: swap;
src: url('/fonts/questrial-v19-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153; /* trimmed here */
}
The page preloads the latin file, so it downloads alongside the stylesheet. The WebFont loader, its inline call, and the Google Fonts preconnects are gone. The version in the file name lets the browser cache /fonts/* as immutable.
Issue 16: Unused CSS and JavaScript
Symptom
Lighthouse still flagged 27 KB of unused CSS. Separately, it flagged 72 KB of unused JavaScript in Google’s gtag.js. I left that one alone. Google serves it as one file, so the only fix is to remove Google Analytics.
Cause
Lighthouse measures only the page it loads. So before I deleted anything, I collected every class and tag across all built pages. No page used Bootstrap’s accordion, breadcrumb, dropdown, pagination, or .table styles. Only the navbar’s collapse used Bootstrap’s JavaScript, but the bundle also shipped Dropdown, Modal, and Tab. The plugin bundle still carried GLightbox (no page uses a lightbox). It also carried a second copy of js-cookie, unused since the cookie banner moved to document.cookie.
Fix
Comment out those Bootstrap imports and plugins. The gzipped sizes:
| File | Before | After |
|---|---|---|
style.css | 27.9 KB | 25.1 KB |
bootstrap.js | 17.0 KB | 4.7 KB |
scripts.js | 21.5 KB | 0.6 KB |
I took screenshots of seven pages (home, two posts, the blog list, contact, a category, and the 404 page). They were pixel-identical before and after, with no console errors. Locally, Lighthouse went from 93 to 97 on the home page and from 94 to 97 on this post. LCP went from 3.0 s to 2.4 s.
Two dashboard details
- Preview links. With
workers_dev: false, the dashboard stopped linking preview URLs, but they kept working. I turned it back on. That also serves the site at theworkers.devhostname. The canonical tag points search engines from that copy tofranzramadhan.dev. - Observability. I enabled Workers logs in the dashboard. I copied the same
observabilityblock intowrangler.jsonc, so deploys keep the setting instead of resetting it. With no Worker script, static asset requests do not run a Worker. So I expect those logs to stay mostly empty.
Final Results
The live home page at the start and at the end, Lighthouse 12 on mobile from my laptop:
| Metric | Start (proxy to Netlify) | End (Workers) |
|---|---|---|
| Performance | 69 | 83 to 85 |
| Accessibility | 98 | 100 |
| Best practices | 82 | 79 to 82 |
| SEO | 100 | 100 |
| First Contentful Paint | 4.6 s | 1.6 to 1.8 s |
| Largest Contentful Paint | 5.3 s | 4.3 s |
| Server response (TTFB) | 640 ms | 90 to 110 ms |
| Transferred | about 570 KiB | 313 KiB |
PageSpeed Insights on desktop gives this post 97 for performance and 100 for accessibility, best practices, and SEO, with FCP at 0.8 s and LCP at 1.0 s.
Mobile LCP is the number that moved least. Without throttling, the home page’s LCP is 1.4 s, and everything loads by 0.6 s. The mobile score simulates a slow phone and network. Most of the simulated delay now comes from three scripts that I chose to keep: Google Analytics (gtag.js, 176 KB and 139 ms of CPU), Cloudflare’s bot detection (131 ms of CPU), and the Cloudflare Web Analytics beacon. Those same scripts cause the deprecation warnings that cap best practices at 79 to 82 in my mobile runs. The site’s own CSS, JavaScript, and font add up to about 46 KB (26 KB CSS, 6 KB JavaScript, 14 KB font).
Lessons
- Dead third-party scripts are easy to miss. A comment service whose domain stopped resolving added a render-blocking request to every post. The only trace was a console error.
- Inlining is not free on a multi-page site. It saves one request on the first page and costs the full payload on every page after it.
- Count what you use before you ship a library. Six icons did not need 322 KB of fonts and CSS, and one cookie did not need a library.
- Check what your benchmark actually loaded. My first comparison measured cross-origin failures, not my changes.
- Two CDNs in a row is one too many. The proxy added uncached HTML and its own scripts. No amount of front-end work fixes TTFB.
- Pin the toolchain in the repo. Build image defaults and dashboard variables drift. A script with versions and checksums does not.
- Check which account a token belongs to before you use it. It took one API call and avoided creating things in the wrong place.
- Build settings are snapshots. Each environment has its own variables. A change to them does not touch builds that are already queued.
- A warm cache can hide a missing tool. A build without Dart Sass passed locally because the compiled CSS was cached. Test the CI path from an empty cache.
- Choose
baseURLper environment./made previews work and broke the sitemap, RSS feed, and share images in production. - Measure unused code across every page. Lighthouse sees one page. The class inventory across all 60 HTML files made it safe to delete CSS.
References
- Chrome for Developers: Lighthouse performance scoring
- web.dev: Largest Contentful Paint
- W3C: Understanding WCAG 2.2 Contrast (Minimum)
- Hugo: Deploy to Cloudflare
- Hugo: css.Sass
- Hugo: hugo gen chromastyles
- Cloudflare: Migrate from Pages to Workers
- Cloudflare: Workers Builds limits and pricing
- Cloudflare: Workers Previews
- Cloudflare: Workers roles and permissions
- Netlify: How credits work
- Font Awesome: Free license
- GitHub: wheresryan22/anatomy
