Closing the mobile PageSpeed gap

Three targeted cuts to initial load (lazy-loading AccountModal, self-hosting variable fonts, and manualChunks CSS consolidation) lift mobile performance.

Desktop PageSpeed scores sat comfortably between 96 and 99 across the home page, Daily Challenge, and AI Duel, but mobile measurements on the exact same routes told a different story: scores hovered between 82 and 84, held back by roughly 1.5 seconds of render-blocking requests before first paint. For a game where nearly all traffic arrives on phones, a desktop score is little more than a vanity metric. Closing that gap did not require an invasive architectural rewrite, but rather three disciplined cuts to the initial waterfall: deferring a dialog almost no one opens on arrival, self-hosting typography, and grouping global styles into a single chunk.

Loading the dialog only on demand#

The first saving came from AccountModal. When WebAuthn passkeys and Google sign-in landed, the modal component was imported statically at the top of the root nudge card, the main menu, and settings. Every first-time visitor was downloading the authentication bundle and its styling immediately, even if they only arrived to play a single daily run. Switching all three call sites to dynamic imports deferred the modal until a player actually opened sign-in, leaving the modal component untouched while updating component tests to poll asynchronously for the dialog:

<!-- SignInNudgeCard.svelte -->
<script lang="ts">
  let AccountModal = $state<any>(null);

  async function openModal() {
    const module = await import('$lib/components/AccountModal.svelte');
    AccountModal = module.default;
  }
</script>

Eliminating the cross-origin font trip#

The second cut replaced external font stylesheets with self-hosted packages. The design system refresh had introduced Baloo 2 and Nunito through standard link tags targeting Google Fonts. That imposed an unavoidable cross-origin handshake before the browser could draw text. Swapping those tags for Fontsource variable packages bundled the full weight spectrum in a single file per family, smaller than the static weight files combined, while Space Mono stayed on dedicated static weights:

/* app.css */
@import '@fontsource-variable/baloo-2';
@import '@fontsource-variable/nunito';
@import '@fontsource/space-mono/400.css';
@import '@fontsource/space-mono/700.css';

:root {
  --font-heading: 'Baloo 2 Variable', sans-serif;
  --font-ui: 'Nunito Variable', sans-serif;
}

Consolidating the shared stylesheet#

The third cut tackled stylesheet fragmentation. By default, the bundler produced multiple separate CSS files for the root layout, global tokens, and navigation elements. An initial build shipped five render-blocking stylesheets on the home page and eight on gameplay routes. Configuring a custom manualChunks rule in the build pipeline consolidated those core files into a single app-shared chunk:

// vite.config.ts
manualChunks(id) {
  if (
    /\/routes\/\+layout\.svelte/.test(id) ||
    /\/src\/app\.css/.test(id) ||
    /\/components\/SupportLink\.svelte/.test(id) ||
    /\/components\/QrCode\.svelte/.test(id) ||
    /\/components\/SignInNudgeCard\.svelte/.test(id)
  ) {
    return 'app-shared';
  }
  return undefined;
}

Getting the grouping right surfaced a subtle bundler quirk: matching only the virtual CSS module path did not pull the component styles into the shared chunk, because stylesheet placement follows the JavaScript module dependency graph. Both the component script and its stylesheet had to be matched. Build verification also caught and rejected an attempt to merge the duel panel’s styles, which accidentally dragged shared gameplay badges and fruit icons into a duel-only file and broke the Daily challenge.

Measuring the waterfall#

The combined changes dropped render-blocking stylesheets from five to two on the home page, and from eight to five on gameplay routes, with zero requests leaving the origin for fonts. In preview testing, the mobile performance score climbed from 82 to 94, cutting first contentful paint by 800 milliseconds and largest contentful paint from 3.6 seconds to 2.7 seconds. With the branch merged into main, the next step is running live verification against the production domain to confirm that real mobile devices see the same improvement.