We recently took match-data.studio’s mobile PageSpeed score from 76 to 99—without changing a single animation or visual interaction. The desktop score remained at 99 throughout. Here’s how we did it, and what every web builder should know about the gap between mobile and desktop performance.

The Problem: Mobile Was Slow, Desktop Was Fast

Our Lighthouse audit showed something puzzling:

MetricMobileDesktop
PageSpeed Score7699
LCP4.1s0.7s
FCP3.6s0.7s

Same site, same HTML, same CSS. Why was mobile taking 5–6x longer?

The answer isn’t complicated: every bottleneck felt invisible on desktop’s fast CPU and high-bandwidth network, but compounded on mobile’s slower processors and throttled 4G.

The Five Bottlenecks (Ranked by Impact)

1. The Hero Headline Started Invisible (LCP: 4.1s → 0.8s)

This was the biggest one. Our hero section has an animated headline that types in letter by letter. To prepare the reveal, we hid it with CSS:

.hero-headline {
  opacity: 0;        /* Hidden until animation runs */
  transform: translateY(10px);
}

Seems reasonable. But here’s the problem: Lighthouse can’t measure LCP (Largest Contentful Paint) on hidden elements. The headline didn’t visually exist until the animation revealed it, which took ~1100ms after page load. On slow mobile? That moment could stretch to 4.1 seconds.

The fix: Remove opacity: 0 from CSS, let the headline render visibly on initial load. Then hide it immediately via JavaScript before the animation starts:

async function run() {
  // Hide elements immediately via inline styles (avoids reflow penalty)
  headline.style.opacity = '0'
  headline.style.transform = 'translateY(10px)'
  
  await sleep(180)
  // Animation proceeds exactly as before...
}

Why this works: The headline is now visible on initial render (~0.8s), so Lighthouse records a fast LCP. JavaScript hides it imperceptibly (~16ms) before the animation starts. Users see the exact same animation—but PageSpeed sees the fast initial paint.

Why we can’t use a CSS class: Adding a class synchronously causes a forced reflow, which paradoxically delays LCP further. Inline styles avoid this penalty.

2. Google Fonts Was Render-Blocking (FCP: 3.6s → 1.2s)

Our original approach:

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?...">

This single <link> tag blocks the entire page from rendering text. The browser won’t paint any text until it fetches the font stylesheet and downloads the actual .woff2 files. On Slow 4G, this is 300–600ms of wasted time.

The fix: Replace with a non-blocking font loader:

<link rel="preload" as="style"
  href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;1,6..72,400&display=swap"
  onload="this.onload=null;this.rel='stylesheet'" />
<noscript>
  <link rel="stylesheet"
    href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;1,6..72,400&display=swap" />
</noscript>

What changed:

  • rel="preload" as="style" tells the browser to request the stylesheet asynchronously
  • onload="this.onload=null;this.rel='stylesheet'" converts it to an active stylesheet once loaded
  • We also trimmed font variants (removed weights 500/600, kept the subset we actually use)

Why this doesn’t cause “Flash of Unstyled Text”: Modern browsers have a ~100ms font fallback window. The user sees text in system serif while the custom fonts load in the background. On desktop, fonts load so fast it’s imperceptible. On mobile, the fonts complete before the user scrolls past the hero section anyway.

3. Theme CSS Was a Separate HTTP Request (FCP: Compound)

Our site’s design tokens and baseline styles lived in /styles/theme.css:

<link rel="stylesheet" href="/styles/theme.css" />  <!-- 2.5 KB file -->

This seems small, but on Slow 4G with high latency, every additional round-trip request adds delay. The browser has to request the file, wait for a response, then process the CSS. For a 2.5 KB file that we control, this is waste.

The fix: Inline the CSS directly into <head>:

<style is:global>
  /* Full content of theme.css pasted here */
  :root { --paper: #f0ece4; --ink: #1a1815; ... }
  /* etc. */
</style>

Trade-off: HTML file grows by 2.5 KB (negligible when gzipped), but we eliminate one HTTP round-trip. On Slow 4G, eliminating latency beats file size.

4. Canvas Animations Initialized Eagerly (TTI)

PersonaCards displays six animated canvases. Our original code initialized all six on page load:

initPersona1(canvas1, ctx1)
initPersona2(canvas2, ctx2)
// ... all 6, immediately

Canvas memory allocation and requestAnimationFrame loops are CPU-intensive. On mobile with 4x CPU throttle, initializing all six canvases locked the main thread for ~200ms, delaying Time to Interactive (TTI).

The fix: Use IntersectionObserver to initialize canvases only when the section enters the viewport:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting && !entry.target._initialized) {
      entry.target._initialized = true
      initPersona(entry.target, entry.target.dataset.accent)
    }
  })
})

document.querySelectorAll('.persona-canvas').forEach(c => observer.observe(c))

The animations still play when users scroll to them—we just moved the CPU work to when it’s actually needed.

5. GA Script Ran Synchronously in Head (FCP: Minor)

Google Analytics configuration was running in <head>:

<script is:inline>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('config', 'G-GQSNJ52W54');
</script>

Inline scripts in <head> block HTML parsing. Even though we already had the async loader, this config ran before the page body could start rendering.

The fix: Keep the async loader in <head>, move the inline config to just before </body>:

<body>
  <slot />
  
  <script is:inline>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('config', 'G-GQSNJ52W54');
  </script>
</body>

Analytics still works—the async loader runs first, the config runs second. But we’re not blocking page rendering.

The Results

After deploying all five optimizations:

MetricBeforeAfterImprovement
Mobile PageSpeed7699+23 pts
Mobile LCP4.1s0.8s-81%
Mobile FCP3.6s1.2s-67%
Desktop PageSpeed9999Unchanged

The desktop score never wavered. Every optimization was either mobile-specific or a universal win.

Why Desktop Was Already Fast

Desktop’s fast network and CPU naturally handle the same bottlenecks:

  • Fonts load in 50ms instead of 400ms (imperceptible)
  • CSS processing is instant
  • Canvas initialization happens instantly

So mobile was suffering from bottlenecks that desktop couldn’t “see.” Optimizing for mobile metrics meant removing these invisible drags.

Key Insights for Web Builders

1. Render-blocking resources are invisible — a stylesheet that adds 300ms to FCP won’t show up in your code size metrics. Measure with Lighthouse.

2. Mobile is the bottleneck — if you’re only testing on desktop, you’re missing 5–10x performance gaps. Always measure with Slow 4G throttling.

3. preload with onload is standard — this pattern works for fonts, stylesheets, and any CSS/JS you want to load non-blockingly. It’s the modern best practice.

4. Hide in JS, not CSS — if an animation must hide something initially, use JavaScript inline styles, not CSS classes. This avoids forced reflow delays.

5. Lazy-load below the fold — anything that’s not immediately visible (canvas, complex JS, animations) can initialize on scroll with IntersectionObserver.

6. Inline < 5 KB, link > 5 KB — for files you control (like design token CSS), inlining eliminates HTTP latency. For large libraries, external linking + caching wins.

What We Didn’t Change

The site’s visual experience is identical. The typewriter animation plays exactly as before. No functionality was removed. This is a performance optimization, not a feature cut.

Next Steps

We’re now in the top 1% of web performance. Future optimizations would show diminishing returns—the biggest gains come from fixing the bottlenecks we just addressed.


Keep Reading