rebjak.com
RSS Slovensky
← Blog

PageSpeed 100 on GitHub Pages — from 87 to a perfect score

Lighthouse showed Performance 87 on mobile. Self-hosted fonts, WCAG contrast, trailing slash — and how to reach 100/100/100/100 even on GitHub Pages. Building in public, part four.

The site has SEO at 100, Best Practices at 100 — but Performance at 87 and Accessibility at 94. Lighthouse clearly showed what needed fixing.

Starting point

After running PageSpeed Insights on rebjak.com/en, I got:

MetricScore
Performance87
Accessibility94
Best Practices100
SEO100

Three main issues:

  1. Google Fonts — render-blocking + external requests, ~2000 ms savings available
  2. Insufficient color contrast — WCAG AA failure in both dark and light mode
  3. Trailing slash redirect/en/en/ costs ~925 ms

1. Google Fonts → self-hosting

Problem

A standard <link rel="stylesheet"> for Google Fonts blocks rendering of the entire page until the font CSS is downloaded. On slower connections, that means a white screen for an extra 1-2 seconds.

Even if you fix the render-blocking with preload, fonts are still downloaded from external servers (fonts.googleapis.com, fonts.gstatic.com). Each external request means:

  • DNS lookup — the browser needs to resolve the domain to an IP address
  • TCP + TLS handshake — a new connection for each server
  • No control over caching — Google sets its own cache headers

On mobile (simulated slow 4G with 150 ms RTT), this adds hundreds of milliseconds on every first load.

Google Fonts serves Inter as a variable font weighing 230 KB and JetBrains Mono at 56 KB — totaling 286 KB over external servers.

Solution

I downloaded the fonts and subsetted them using pyftsubset (from the fonttools library) to Latin + Latin Extended-A (U+0000-017F) — covering both English and Slovak (č, š, ž, ľ, ď, ť, ň and more).

pyftsubset inter-latin.woff2 \
  --output-file=inter-latin.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga,clig,calt' \
  --unicodes="U+0000-017F,U+2000-206F,U+20AC"

The resulting sizes:

FontBefore (Google)After (subset)Savings
Inter230 KB45 KB–80%
JetBrains Mono56 KB32 KB–43%
Total286 KB77 KB–73%

In global.css, I added @font-face declarations:

@font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 400 700;
    font-display: swap;
    src: url('/fonts/inter-latin.woff2') format('woff2');
    unicode-range: U+0000-017F, U+2000-206F, U+20AC;
}

And in BaseLayout.astro, I replaced all Google Fonts links with simple preload declarations:

<!-- Before: 4 links to external servers -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?..." onload="..." />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?..." /></noscript>

<!-- After: 2 preload links to own server -->
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin.woff2" crossorigin />
<link rel="preload" as="font" type="font/woff2" href="/fonts/jetbrains-mono-latin.woff2" crossorigin />

font-display: swap shows text immediately with a fallback font (system-ui) and switches to Inter/JetBrains Mono once loaded. The user sees content right away — the font swap happens without noticeable flashing.

2. Color contrast (WCAG)

Problem

Lighthouse flagged several text elements where the text color didn’t have sufficient contrast against the background. WCAG AA requires at least 4.5:1 for normal text and 3:1 for large text.

The first iteration fixed the worst cases, but a Lighthouse CLI test revealed that zinc-500 (#71717a) on dark backgrounds still didn’t reach 4.5:1. Similarly, zinc-400 (#a1a1aa) on white has only 2.56:1.

Tailwind zinc scale contrast ratios

ColorHexvs whitevs dark bg (~#0f1319)
zinc-300#d4d4d81.48:1
zinc-400#a1a1aa2.56:15.63:1
zinc-500#71717a4.83:13.97:1
zinc-600#52525b7.73:1

Solution

The correct pattern for secondary text: text-zinc-500 dark:text-zinc-400 — both values meet 4.5:1 in their respective modes.

ElementBeforeAfter
Stats labelstext-zinc-400 dark:text-zinc-600text-zinc-500 dark:text-zinc-400
Nav card labelstext-zinc-400 dark:text-zinc-600text-zinc-500 dark:text-zinc-400
”open →” texttext-zinc-300 dark:text-zinc-700text-zinc-500 dark:text-zinc-400
Footer text + linkstext-zinc-400 dark:text-zinc-600text-zinc-500 dark:text-zinc-400
Terminal title bartext-zinc-400 dark:text-zinc-600text-zinc-500 dark:text-zinc-400
CV section headerstext-zinc-400 dark:text-zinc-500text-zinc-500 dark:text-zinc-400
Blog tag countstext-zinc-400 dark:text-zinc-500text-zinc-500 dark:text-zinc-400

For the terminal output on the homepage, I replaced inline color:#64748b (slate-500, 3.91:1 contrast on dark) with a CSS class that switches between dark and light modes:

/* dark mode default */
.term-out { color: #94a3b8; }  /* slate-400 — 7.26:1 on dark */

/* light mode override */
:root:not(.dark) .term-out { color: #475569; }  /* slate-600 — 7.58:1 on white */

In total, 12 files were updated — both homepages, Header, Footer, both CVs, blog listings (SK/EN), and blog tags (SK/EN).

3. Trailing slash redirect

Problem

GitHub Pages defaults to redirecting /en to /en/ via a 301 redirect. PageSpeed reported this as ~925 ms of unnecessary latency — the browser has to make an extra round-trip to the server.

Solution

I set trailingSlash: 'always' in the Astro config:

// astro.config.mjs
export default defineConfig({
  site: 'https://rebjak.com',
  trailingSlash: 'always',
  // ...
});

And updated all internal links across the project to include trailing slashes:

<!-- Before -->
<a href="/en/blog">Blog</a>
<a href="/cv">CV</a>

<!-- After -->
<a href="/en/blog/">Blog</a>
<a href="/cv/">CV</a>

Same for dynamic links:

<!-- Before -->
<a href={`/blog/tag/${tag}`}>#{tag}</a>

<!-- After -->
<a href={`/blog/tag/${tag}/`}>#{tag}</a>

In total, I updated links across 12 files — homepages, blog lists, tag pages, slug pages, and the Header component navigation.

Result

How to test locally
# build + serve
npx astro build && npx serve dist -l 4444

# in a second terminal
npx lighthouse http://localhost:4444/en/ \
  --chrome-flags="--headless=new" \
  --output=html \
  --output-path=./lighthouse-report.html

Local (localhost)

PagePerformanceAccessibilityBest PracticesSEO
/ (SK)100100100100
/en/ (EN)100100100100
/blog/100100100100
/en/blog/100100100100

Production (GitHub Pages)

A local 100 isn’t the full story. Lighthouse on the production server tests with real network latency — and the mobile preset simulates slow 4G (1.6 Mbps, 150 ms RTT).

Before optimization:

PresetPerformanceAccessibilityBest PracticesSEO
Desktop9794100100
Mobile8794100100

After all optimizations:

PresetPerformanceAccessibilityBest PracticesSEO
Desktop100100100100
Mobile100100100100

100/100/100/100 on both desktop and mobile. Performance jumped from 87 to 100 — FCP dropped from 3.0 s to 1.1 s, LCP from 3.0 s to 2.1 s. Accessibility from 94 to 100.

Before and after metrics (mobile)

The Lighthouse mobile preset simulates real-world conditions — slow 4G (1.6 Mbps, 150 ms RTT) on a Moto G Power device. According to Google’s Think with Google, 53% of mobile visitors abandon a site that takes more than 3 seconds to load.

MetricBeforeAfterChange
First Contentful Paint3.0 s1.1 s–63%
Largest Contentful Paint3.0 s2.1 s–30%
Speed Index4.4 s2.6 s–41%
Total Blocking Time0 ms0 ms
Cumulative Layout Shift00

FCP dropped to a third of the original — the user sees content almost instantly. LCP from 3.0 s to 2.1 s means the main content is fully rendered in ~2 seconds. TBT 0 and CLS 0 — the page is immediately functional and nothing jumps around.

Optimization summary

OptimizationImpactStatus
Self-hosting fontsEliminates 3 external requestsDone
Font subsetting–73% font size (286 → 77 KB)Done
Trailing slash fixEliminates 301 redirect (~925 ms)Done
WCAG contrast (dark + light)100% AccessibilityDone
Terminal colors via CSS classesWCAG AA in both modesDone
Preconnect to Umami APISaves ~270 ms LCPDone

GitHub Pages limitations — what to watch out for

Even though we achieved 100, GitHub Pages has hard limits that can affect larger sites:

Cache headers — GitHub Pages sets Cache-Control: max-age=600 (10 minutes) for all static assets. Even for files with hashed names (e.g. _page_.DYzwY8gP.css) that should ideally have max-age=31536000 (1 year) with the immutable flag. You have no control over this — GitHub Pages doesn’t support custom cache headers.

No edge caching — content is served from a single region. CDNs like Cloudflare or Vercel have edge nodes worldwide and serve from the closest server.

No compression control — you can’t configure Brotli compression instead of gzip, or optimize response headers.

For a small static site without large images, you can achieve 100/100/100/100 even on GitHub Pages. For larger sites with more assets, these limits could cost points — that’s when a CDN like Cloudflare or migrating to Vercel/Netlify is worth considering.

What’s next?

  • Breadcrumb schema for blog posts
  • Blog post series schema (isPartOf)
  • Lazy loading for below-the-fold images