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:
| Metric | Score |
|---|---|
| Performance | 87 |
| Accessibility | 94 |
| Best Practices | 100 |
| SEO | 100 |
Three main issues:
- Google Fonts — render-blocking + external requests, ~2000 ms savings available
- Insufficient color contrast — WCAG AA failure in both dark and light mode
- 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:
| Font | Before (Google) | After (subset) | Savings |
|---|---|---|---|
| Inter | 230 KB | 45 KB | –80% |
| JetBrains Mono | 56 KB | 32 KB | –43% |
| Total | 286 KB | 77 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
| Color | Hex | vs white | vs dark bg (~#0f1319) |
|---|---|---|---|
| zinc-300 | #d4d4d8 | 1.48:1 | — |
| zinc-400 | #a1a1aa | 2.56:1 | 5.63:1 |
| zinc-500 | #71717a | 4.83:1 | 3.97:1 |
| zinc-600 | #52525b | 7.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.
| Element | Before | After |
|---|---|---|
| Stats labels | text-zinc-400 dark:text-zinc-600 | text-zinc-500 dark:text-zinc-400 |
| Nav card labels | text-zinc-400 dark:text-zinc-600 | text-zinc-500 dark:text-zinc-400 |
| ”open →” text | text-zinc-300 dark:text-zinc-700 | text-zinc-500 dark:text-zinc-400 |
| Footer text + links | text-zinc-400 dark:text-zinc-600 | text-zinc-500 dark:text-zinc-400 |
| Terminal title bar | text-zinc-400 dark:text-zinc-600 | text-zinc-500 dark:text-zinc-400 |
| CV section headers | text-zinc-400 dark:text-zinc-500 | text-zinc-500 dark:text-zinc-400 |
| Blog tag counts | text-zinc-400 dark:text-zinc-500 | text-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)
| Page | Performance | Accessibility | Best Practices | SEO |
|---|---|---|---|---|
/ (SK) | 100 | 100 | 100 | 100 |
/en/ (EN) | 100 | 100 | 100 | 100 |
/blog/ | 100 | 100 | 100 | 100 |
/en/blog/ | 100 | 100 | 100 | 100 |
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:
| Preset | Performance | Accessibility | Best Practices | SEO |
|---|---|---|---|---|
| Desktop | 97 | 94 | 100 | 100 |
| Mobile | 87 | 94 | 100 | 100 |
After all optimizations:
| Preset | Performance | Accessibility | Best Practices | SEO |
|---|---|---|---|---|
| Desktop | 100 | 100 | 100 | 100 |
| Mobile | 100 | 100 | 100 | 100 |
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.
| Metric | Before | After | Change |
|---|---|---|---|
| First Contentful Paint | 3.0 s | 1.1 s | –63% |
| Largest Contentful Paint | 3.0 s | 2.1 s | –30% |
| Speed Index | 4.4 s | 2.6 s | –41% |
| Total Blocking Time | 0 ms | 0 ms | — |
| Cumulative Layout Shift | 0 | 0 | — |
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
| Optimization | Impact | Status |
|---|---|---|
| Self-hosting fonts | Eliminates 3 external requests | Done |
| Font subsetting | –73% font size (286 → 77 KB) | Done |
| Trailing slash fix | Eliminates 301 redirect (~925 ms) | Done |
| WCAG contrast (dark + light) | 100% Accessibility | Done |
| Terminal colors via CSS classes | WCAG AA in both modes | Done |
| Preconnect to Umami API | Saves ~270 ms LCP | Done |
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