Back to Home

Fixing My Blog's Lighthouse Scores, Then Moving It to Cloudflare Workers

| October 15, 2026 | 23 min to read

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-hugo theme (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:

PagePerformanceAccessibilityBest practicesFCPLCP
Home6998824.6 s5.3 s
Post7388793.6 s4.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 --minify only 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, but WebFont now came from a deferred file, so it threw ReferenceError: WebFont is not defined. Wrapping it in DOMContentLoaded 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 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 to Menu.
  • Inline code: Bootstrap’s default #d63384 on 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 empty custom.scss that shadows the theme’s file, so the override has to live in exampleSite/assets/scss/custom.scss.
  • Syntax highlighting: two onedark colors fail on the code block background #25303a:
ColorUsed forBeforeAfter
#e06c75 to #e37981names, YAML keys, tags4.21:14.68:1
#7f848e to #9398a0comments3.58:14.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):

PageMetricBeforeAfter
HomePerformance8994
PostPerformance8296
PostAccessibility88100
PostBest practices79100

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 partTime
Time to first byte1,097 ms
Resource load delay864 ms
Resource load time3,074 ms
Element render delay89 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

OptionTTFBInjected scriptsKeeps Cloudflare WAFEffort
A. DNS only (grey cloud), Netlify serves directlyFixedGoneNo5 min
B. Keep proxy, cache HTML, purge on every deployFixed while cache is warmOff if disabledYesMedium, and a missed purge serves a stale site
C. Keep proxy, only disable the extrasNot fixedGoneYes5 min
D. Move hosting to CloudflareFixed (one CDN)OptionalYesAbout an hour, plus testing
E. Preload the hero image onlyNot fixedNo changeYesSmall

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 main branch 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 error on 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:

  1. Found a .tool-versions file in repository root. Installing dependencies. then Failed. The file was from my asdf days (golang 1.21.4, direnv 2.33.0) next to an .envrc containing only use asdf. I use mise now. Both files went.
  2. Your Wrangler configuration is missing a previews block. wrangler preview (open beta) needs "previews": {} even if it is empty.
  3. you need the extended version to build SCSS/SASS with transpiler set to 'libsass'. HUGO_VERSION=0.168.0 installed the standard edition. Setting extended_0.168.0 did not help.
  4. The fix that worked: stop relying on the image. Following Hugo’s own Cloudflare deployment guide , scripts/build.sh installs 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 is franzramadhan.dev, and Hugo reads a dotted name as a module path. build.sh now symlinks the repo root as hydrogen-hugo in 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-workers becomes https://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:

WorkersCloudflare proxy to Netlify
Performance (2 runs)71, 7972, 75
Best practices10082
TTFB (4 requests)0.23 to 0.75 s0.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:

  1. Stop Netlify builds, so its last good deploy keeps serving while the PR is open.
  2. Add the domain to wrangler.jsonc, with workers_dev off so the site is not also served on a second hostname:
"routes": [{ "pattern": "franzramadhan.dev", "custom_domain": true }],
"workers_dev": false,
  1. Delete the apex CNAME to apex-loadbalancer.netlify.com, then run wrangler deploy right away. Cloudflare created the DNS record and certificate, and the site was down for less than a minute.
  2. www used to redirect through Netlify. It is now a proxied AAAA 100:: placeholder plus a Redirect Rule (https://www.* to https://${1}, 301, query string kept). For about a minute after deploying the rule, www returned 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:

PhaseTimeWhat runs
Initializing3m50sCloudflare starts the build machine, before the clone
Cloning5sgit clone
Installing336 msskipped by SKIP_DEPENDENCY_INSTALL
Deploying1m49sbuild.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:

FileBeforeAfter
style.css27.9 KB25.1 KB
bootstrap.js17.0 KB4.7 KB
scripts.js21.5 KB0.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: false the dashboard stopped linking preview URLs, even though they kept working. I turned it back on. That also serves the site at the workers.dev hostname, and the canonical tag points search engines from that copy to franzramadhan.dev.
  • Observability. I enabled Workers logs in the dashboard and copied the same observability block into wrangler.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:

MetricStart (proxy to Netlify)End (Workers)
Performance6983 to 85
Accessibility98100
Best practices8279 to 82
SEO100100
First Contentful Paint4.6 s1.6 to 1.8 s
Largest Contentful Paint5.3 s4.3 s
Server response (TTFB)640 ms90 to 110 ms
Transferredabout 570 KiB313 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

  1. 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.
  2. 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.
  3. 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.
  4. Check what your benchmark actually loaded. My first comparison measured cross-origin failures, not my changes.
  5. 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.
  6. Pin the toolchain in the repo. Build image defaults and dashboard variables drift; a script with versions and checksums does not.
  7. Check which account a token belongs to before using it. It took one API call and avoided creating things in the wrong place.
  8. Build settings are snapshots. Each environment has its own variables, and changing them does not touch builds that are already queued.
  9. 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.
  10. Choose baseURL per environment. / made previews work and quietly broke the sitemap, RSS feed, and share images in production.
  11. 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