Reading Lighthouse Scores
The performance audit runs Lighthouse after a deploy. This page is about reading its score. A fast CWA page can still score badly on mobile, and the cause is usually a choice the project made, not the server.
One downstream site shows how much this varies. Its page /graphs/top-line-findings is mostly text, paints in 282 ms without throttling, and is served from Cloudflare's edge cache. On mobile Lighthouse it scored 60, 74, 68 and 96 in four runs. With Google Tag Manager blocked it scored 94–96. These are that site's figures, not a benchmark, but the pattern is common.
Mobile scores are estimates
Lighthouse has two ways to slow a page down to a mid-range phone on Slow 4G with a 4× CPU slowdown:
- Simulated throttling (
simulate) loads the page at full speed, then estimates the slow-phone timings from the requests it saw. PageSpeed Insights and Lighthouse's own defaults use it. A request that starts before the first paint adds to the estimate, even at low priority, and the result can swing between runs. - Real throttling (
devtools) slows the network and CPU while the page loads, and measures what happens. The template's audit uses it by default (CWA_CI_PERFORMANCE_AUDIT_THROTTLING), because simulated throttling gave very different scores for the same page on shared CI runners.
So before chasing a number:
- Judge on the median of several runs. The audit runs each page 5 times and reports the median. A single PageSpeed Insights run proves little.
- Compare the observed timings with the estimated ones. In a simulated report's JSON,
audits.metrics.details.items[0]hasobservedFirstContentfulPaintandobservedLargestContentfulPaint(unthrottled) next tofirstContentfulPaintandlargestContentfulPaint(estimated). A page that paints in a few hundred milliseconds but is estimated at several seconds is being penalised for what it requests early, not for being slow. - Look at what loads before the first paint. The report's network requests and "Reduce unused JavaScript" findings usually point straight at the cause.
audits.metrics.details.items[0], highlighting the observed and estimated FCP side by side.Third-party scripts
Don't let analytics download before the first paint. This was by far the biggest cost on the page above.
@nuxt/scripts warms up a script's connection for you. Its warmupStrategy option defaults to 'preload' when the script's trigger is 'client' or 'onNuxtReady', and a registry script such as Google Tag Manager uses 'client' unless you give it another trigger. A preload puts the script, for example gtm.js, in the page's <head>, so GTM and the gtag it loads, about 300 KiB on that site, land in the critical path.
Use 'preconnect' (or false) instead:
useScriptGoogleTagManager({
id: 'GTM-XXXXXXX',
scriptOptions: {
warmupStrategy: 'preconnect', },
})
The trigger and consent handling don't change; only the early download goes. On the page above this was worth about 25 points. The option takes false, 'preload', 'preconnect' or 'dns-prefetch', and a script with a custom trigger, such as a consent promise, gets no warm-up by default. This was checked against @nuxt/scripts 1.3.12.
Icons
Ship small favicons and manifest icons. The browser fetches the icon on a first visit at high priority, so a large one competes with the page. The downstream site's icons were 2000×2000 PNGs of about 300 KiB each. Resized to 192px and 512px and palette-reduced, they were about 2 KiB and 8 KiB.
The template already does this. Its PWA manifest lists pwa-192x192.png and pwa-512x512.png, generated from app/public/icon.png by pnpm generate-assets (@vite-pwa/assets-generator). Replace icon.png with your own logo, run the script again, and don't point the manifest or a <link rel="icon"> at the source image. See PWA.
Images
Upload PNG or JPEG; visitors get WebP. From template 2.0.0-alpha.25, every image variant (thumbnail, general and large) is WebP whatever the upload's format (format: webp, quality: 80 in api/config/packages/liip_imagine.yaml). Editors don't need to convert logos or screenshots. On the downstream site, a 128 KB general PNG logo became 41.5 KB.
A project with its own filter sets needs format: webp on each one. See WebP and other output formats. Production deploys also create variants ahead of time, so a visitor's request doesn't have to; see Generating image variants after a deploy.
Render images with srcset and sizes, as the template's Image.vue does, so a phone downloads the small variant. See Images & Uploads → Responsive images.
What the stack already does
So nobody repeats it:
- Compressed build assets.
/_nuxtfiles are compressed with Brotli and gzip when the app is built, by theprecompressBuildAssetshelper inapp/nuxt.config.ts(template 2.0.0-alpha.28; not Nitro'scompressPublicAssets). See Docker. - Page caching. Souin caches rendered pages and API responses at the origin (Page Caching). Cloudflare can cache them at the edge too, if you choose Option B.
- The editor loads lazily. The template's TipTap editor is its own chunk, and its prefetch hint is removed from every page. Do the same for any heavy client library, such as a charting library, so it loads only on pages that use it.
- Fonts. Nuxt UI registers
@nuxt/fonts, which self-hosts the fonts your CSS uses. - Admin code stays out of visitors' pages. From
@cwa/nuxt2.0.0-alpha.11 (cwa-nuxt-module#382), the Mercure client loads after hydration, and the confirm dialog, the orphan report and the page cache warm reader load on first use. Anonymous visitors' pages no longer preload the admin's component manager or the empty-position placeholder. Live updates for visitors are unchanged.
What is still the framework's cost
- The module's core classes are still in the visitor entry.
Admin, its stack manager and reorders, the resources manager,Formsand the HTTP cache code are built into every visitor's entry. Loading the stack manager and reorders only for admins would save about 4 KB gzip per visitor, and was declined as not worth a breaking change. The module's build checks the entry and an anonymous page's preloads against a size budget, so it shouldn't grow unnoticed. <UApp>in the template'sapp/app/app.vue. Nuxt UI's wrapper costs every page about 162 KB minified, 44 KB brotli, of JavaScript, measured by an A/B production build: its toaster, Reka UI's toast, tooltip and overlay providers, andtailwind-variants. That's about a quarter of what every page loads. The template keeps it on purpose (components-web-app#150), because visitor toasts such as form feedback, tooltips and modals need it.:toaster="null"or a lazily loaded wrapper saves nothing, because the code is still on the hydration path.
<UApp> from its own app/app/app.vue and save those 44 KB. In a check without it, visitor pages, Nuxt UI form selects, the admin and edit mode all worked unchanged. If the site later uses useToast(), UTooltip or useOverlay, add <UApp> back.A target to aim for
A CWA page with no heavy third-party scripts should score 90 or more on mobile, as the median of 5 runs. The downstream page above scored 94–96 once its tag manager stopped downloading early. If yours scores well below that, check the sections above in order: third-party scripts, icons, images, then your own components.
The score is about one visitor on a slow phone. For how the server holds up under many visitors, and whether pages are served from the cache, run a load test.
Next steps
- Performance Audit: running the audit, its budgets and its reports.
- CI/CD → Auditing performance after a deploy: where the job sits in the pipeline.
- Load Testing: how many visitors the site can serve.
Performance Audit
Run Lighthouse CI against a deployed CWA site to see what a visitor experiences — LCP, CLS, TBT and page weight against budgets — without ever failing a deploy.
Faster Functional Tests
When a project's functional test suite slows CI down — measure where the time goes, reset the database without rebuilding the schema, and shard the job by test.