Intro
I almost replaced this blog’s theme. I liked the look of another Hugo theme, and before migrating 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, the fixes, why production stayed slow after the fixes were merged, and why that led me from Netlify to Cloudflare Workers. I did the work in one session with Claude Code: it ran the audits, proposed the fixes, and I made the calls on tradeoffs.
TL;DR: Mobile performance was 69 because of a render-blocking comment script whose domain no longer resolves, a preloader that hid the page until every script finished, a lazy-loaded hero image, about 410 KB of CSS and JS inlined into every page, and 322 KB of Font Awesome for six icons. Fixing those took local scores to 94 (home) and 96 (post). Production barely moved because the site sat behind the Cloudflare proxy in front of Netlify, with uncached HTML and Cloudflare-injected scripts. I moved hosting to Cloudflare Workers static assets, built from GitHub by Workers Builds.
Update: after the cutover I also cut the build time by vendoring Hugo modules, fixed relative URLs in the sitemap, RSS feed, and share previews that dated back to Netlify, 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, and the home page scores 83 to 85 on mobile. See After the Cutover and Final Results .
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, and one script flagged 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 has to try, and it cannot render past a synchronous script until the request fails. The comments themselves had been 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 above 4 s even though 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, including 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
The home page hero image is rendered by a shared image partial that defaults to loading="lazy". Lazy loading the largest above-the-fold image delays the exact element 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 none of it can be cached, 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 --minifyonly minifies HTML output, including inline blocks. Once 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 it threwReferenceError: WebFont is not defined. Wrapping it inDOMContentLoadedfixed 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 just 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 from cdnjs (151 KB and 111 KB) plus 62 KB of Font Awesome CSS, and Lighthouse’s “Reduce unused CSS” pointed straight 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/, and added a partial that inlines them. It accepts the existing config 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 there, I found 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>right under the page<h1>. Changed to<h2 class="h3">so they look the same. - Menu button: visible text
MENU,aria-label="Toggle navigation". 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, because the site has its own emptycustom.scssthat shadows the theme’s file, so the override has to 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 and 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, and the browser blocked them as cross-origin requests (the tags carry crossorigin="anonymous"). Rebuilding both versions with --baseURL http://localhost:<port>/ 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 merging, 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 deployed (the CSS was served with immutable, transfer size was down to 344 KB), so something outside the repo was costing 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, and TTFB from my laptop ranged from 0.5 to 1.2 s. On top of that, 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 that capped best practices at 82 (StorageType.persistent, Fledge) 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, so 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. Hosting there leaves one CDN in the path and keeps the proxy features I would lose with option A.
On the Cloudflare side, Pages was the obvious name, but Cloudflare now recommends Workers static assets for new sites, with new features going to Workers. For a static site it needs no Worker code: point it at the build output. Cost is not a concern at this size: static asset requests are free, the free tier has 3,000 build minutes a month (a build here takes about a minute), and every branch gets a preview URL.
What I give up: Netlify-specific features, mainly Netlify Agent Runners, which I had only set up a few days earlier.
The Migration
Setup
The whole hosting config 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, which Workers static assets reads and does not serve 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, other branches run npx wrangler preview, and both run build.command first.
Issue 9: The Token in My Shell Belonged to Another Account
The first API call showed the token could see exactly one account, and it was a work account, not the one that owns franzramadhan.dev. The zone lookup returned nothing. Nothing was created there. I made a separate personal token and exported it under different names (CF_PERSONAL_API_TOKEN, CF_PERSONAL_ACCOUNT_ID), passing 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 that the token screen no longer offers, and Workers Admin returns
Authentication erroron that API. So build settings live in the dashboard, along with the GitHub App install, which cannot be done through the API 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 an.envrccontaining onlyuse asdf. I use mise now. Both files went.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. Settingextended_0.168.0did not help.- The fix that worked: stop relying on the image. Following 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 set in the build variables, the image skips its own tool install, so there is no .tool-versions or HUGO_VERSION to trip over. Locally the same versions come from mise.toml.
Moving from LibSass to Dart Sass
LibSass is deprecated since Hugo 0.153 and only ships 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, enough to bury a real error in the build log, hence the silence list. To check nothing changed visually, I screenshotted the home page, a post, the contact page, and a category page from both builds and diffed them: 0 differing pixels on all four.
Two smaller things
- Theme folder name. The old build used
--themesDir ../.., which only works when the checkout folder is named like 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 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
Comparing 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: some of my requests are routed through Paris (cf-ray ended in CDG), so my absolute numbers are noisy, and the remaining cost is the render path itself (the stylesheet, Questrial through the WebFont loader, and the hero image), which is the same on both hosts. Issues 15 and 16 below deal with that part.
The cutover
A Worker Custom Domain cannot be attached while another DNS record exists for the same name, so the order mattered:
- Stop Netlify builds, so its last good deploy keeps serving while the PR is open.
- Add the domain to
wrangler.jsonc, withworkers_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 deployright away. 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 deploying the rule,wwwreturned 522: the request reached the placeholder origin because the rule had not propagated to every edge yet.
The email records were not touched.
After the Cutover
The domain worked, but the next few merges turned up 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 had only updated Previews Base. A build also keeps the variables it was created with, so fixing the tab later 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. That was expected, since 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. Only with an empty resource cache did it fail like Netlify:
HUGO_RESOURCEDIR="$(mktemp -d)" hugo --minify --gc # no sass on PATH
# ERROR ... TOCSS: failed to transform "style.scss" ...
The Linux container test I had 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, which were leftovers from the theme template (Hugo 0.98, January 2024), and 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 mine: build.sh downloaded Go (about 70 MB) only so Hugo could fetch its modules (about 15 s), and npx downloaded wrangler (about 8 s).
Fix
Vendor the modules 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 now downloads Dart Sass and Hugo in parallel. Locally, a clean build with only sass and hugo on the PATH and an empty Hugo cache takes about a second.
The cost is that _vendor has to follow every module change. A local pre-commit hook runs hugo mod vendor whenever go.mod, go.sum, or module.toml is staged, and 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
Hooks can be skipped, so build.sh also compares _vendor/modules.txt with module.toml and go.mod using sed and awk, and fails with _vendor has <module> <version>, go.mod wants <version> (run: hugo mod vendor). Testing it with a real module update found two bugs in my first version: the hook counted already-staged changes, so it could never pass, and hugo mod get hands trailing flags to go get, so --themesDir was silently ignored. The hook now only looks at unstaged and untracked files, and 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 previews would load their own CSS. Every absolute URL Hugo generates became a path. Sitemaps require absolute URLs, feed readers cannot follow relative links, and 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, which 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, yet LCP was still about 5 s, and most of it was render delay (3.4 to 4.0 s): the hero image finished downloading in under a second and then waited to be painted.
Cause
Questrial was loaded by the WebFont loader, a script in the deferred bundle. The font request could only start after that script ran: a new connection to fonts.googleapis.com, a stylesheet, then the font file from fonts.gstatic.com. In the trace the Google Fonts stylesheet started at 322 ms, after everything else on the page had arrived, and first paint waited for that chain.
Fix
Self-host the font. Questrial is licensed under the SIL OFL, which allows it, so I downloaded the same woff2 subsets Google serves and 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, and the WebFont loader, its inline call, and the Google Fonts preconnects are gone. The version in the file name lets /fonts/* be cached 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. That one I left alone: Google serves it as one file, so the only fix is removing Google Analytics.
Cause
Lighthouse only measures the page it loads, so before deleting anything I collected every class and tag across all built pages. Bootstrap’s accordion, breadcrumb, dropdown, pagination, and .table styles were used on no page, and only the navbar’s collapse used Bootstrap’s JavaScript, while the bundle also shipped Dropdown, Modal, and Tab. The plugin bundle still carried GLightbox (no page uses a lightbox) and 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 |
Screenshots of seven pages (home, two posts, the blog list, contact, a category, and the 404 page) 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, with LCP from 3.0 s to 2.4 s.
Two dashboard details
- Preview links. With
workers_dev: falsethe dashboard stopped linking preview URLs, even though they kept working. I turned it back on. That also serves the site at theworkers.devhostname, and the canonical tag points search engines from that copy tofranzramadhan.dev. - Observability. I enabled Workers logs in the dashboard and 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 has loaded by 0.6 s. The mobile score simulates a slow phone and network, and most of the simulated delay now comes from three scripts 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 fail quietly. A comment service whose domain stopped resolving cost every post a render-blocking request, and 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, and 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 using it. It took one API call and avoided creating things in the wrong place.
- Build settings are snapshots. Each environment has its own variables, and changing 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 quietly 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 is what 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