Back to Home
post-thumb

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

| October 15, 2026 | 24 min to read

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

ms
The baton: one request for the home pageBack lane: Cloudflare proxy, then Netlify. Front lane: Workers. Crates: page weight the fixes removedLighthouse 12 on mobile, live home page. At the start every request ran through the Cloudflare proxy, which does not cache HTML (DYNAMIC), on to Netlify's edge and back: TTFB 640 ms, performance 69. The fixes threw out a dead comment script, 410 KB of inlined CSS and JS and 322 KB of Font Awesome, but the proxied site still measured 1,097 ms to first byte. On Workers static assets the Cloudflare edge serves the page itself: TTFB 90 to 110 ms, performance 83 to 85. Runs play at one fifth of real time.

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-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. 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 --minify minifies 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. But WebFont now came from a deferred file, so the call threw ReferenceError: WebFont is not defined. I wrapped it in DOMContentLoaded, 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 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. The site has its own empty custom.scss that shadows the theme’s file, so the override must 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. 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):

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 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 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. 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

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. That is 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. 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 error on 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:

  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 it was an .envrc with only use asdf. I use mise now. I deleted both files.
  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. extended_0.168.0 did not help.
  4. The fix that worked: stop relying on the image. I followed 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 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 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 that 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

I compared 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 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:

  1. Stop Netlify builds, so its last good deploy keeps serving while the PR is open.
  2. Add the domain to wrangler.jsonc. Set 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 immediately. 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 I deployed the rule, www returned 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:

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 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:

FileBeforeAfter
style.css27.9 KB25.1 KB
bootstrap.js17.0 KB4.7 KB
scripts.js21.5 KB0.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 the workers.dev hostname. The canonical tag points search engines from that copy to franzramadhan.dev.
  • Observability. I enabled Workers logs in the dashboard. I 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 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

  1. 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.
  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. 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 you use 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. A change to 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 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 made it safe to delete CSS.

References