Skip to main content
PercherPercher

← Blog

Self-hosting fonts fixed a 7-second first paint

A Percher app had fast server responses but a blank page for seven seconds. The culprit was a blocked Google Fonts request, not the app server.

A user reported a slow Percher app. First visit took about seven seconds before anything painted.

The usual suspects were clean. TTFB was under 100 ms. The bundle was small. Percher-served assets were fast.

One request was not: fonts.googleapis.com/css2?family=....

It sat in (pending) for several seconds, then timed out. The browser was waiting on a third-party font stylesheet before it painted the page.

The actual problem

The app loaded fonts directly from Google Fonts. That works until the user's network cannot reach Google's font CDN quickly. The reason can be boring: corporate firewall, Pi-hole, regional routing, a transient CDN issue. It does not matter much. The render path now depends on a domain you do not control.

Percher was serving the app quickly. The browser was stuck waiting somewhere else.

Why generated apps hit this

Generated React and Vite starters often include a Google Fonts <link> in the document head. It is convenient, free, and makes the default UI look less bare.

The cost is hidden: first paint depends on a third-party request. If that request stalls, the app looks broken even though your origin is fine.

Service workers can make it worse. Some starter apps cache aggressively. If a bad font request gets cached or the worker keeps serving a stale state, the user can keep seeing slow loads after the network problem is gone. Unregistering the service worker and hard reloading is a useful sanity check.

How to spot it

Open DevTools Network. If your HTML, JS, CSS, and images from your own origin are fast, look for one long pending request to:

  • fonts.googleapis.com
  • fonts.gstatic.com
  • another hosted font or icon CDN

If unregistering the service worker under Application -> Service Workers makes the next reload fast, the worker was part of the problem.

The fix

Self-host the font files. Ship them from your own origin.

For Next.js, use next/font/google:

```ts // app/layout.tsx import { DM_Sans, JetBrains_Mono } from "next/font/google";

const dmSans = DM_Sans({ subsets: ["latin"], display: "swap" }); const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], display: "swap" });

export default function RootLayout({ children }) { return ( <html className={dmSans.className}> <body>{children}</body> </html> ); } ```

Next downloads the font at build time and serves it from your app. There is no runtime request to Google.

For Vite, SvelteKit, Astro, or Solid, use @fontsource:

``bash bun add @fontsource/dm-sans @fontsource/jetbrains-mono ``

``css @import "@fontsource/dm-sans/400.css"; @import "@fontsource/dm-sans/500.css"; @import "@fontsource/jetbrains-mono/400.css"; ``

For plain HTML, download the WOFF2 files, put them under public/fonts/, and define @font-face:

``css @font-face { font-family: "DM Sans"; src: url("/fonts/dm-sans-400.woff2") format("woff2"); font-weight: 400; font-display: swap; } ``

Redeploy after the change. If a service worker cached the bad path, unregister it or hard reload twice while testing.

The rule

Anything needed for first paint should come from your origin. Fonts are the common one, but the same rule applies to icon CDNs, blocking analytics, avatar services, and hosted scripts.

Once the critical path is yours, slow pages are easier to debug. If the browser has to wait on someone else's domain before it can paint, you have less control than you think.

Percher can serve your app quickly. It cannot make the browser's third-party requests fast. Start with fonts.