devShakib

Core Web Vitals for CanvasKit Flutter Web: What You Can Actually Move

Core Web Vitals for CanvasKit Flutter Web are broken by design. Learn why LCP, CLS and INP mislead, and exactly which loading, layout and frame levers you can move.

The first time I ran PageSpeed Insights against a CanvasKit Flutter build, I got a beautiful, misleading picture: near-perfect CLS, a Largest Contentful Paint that pointed at the loading spinner, and an interactivity score that had nothing to do with any interaction a user could actually perform. Core Web Vitals were designed for a document — a tree of DOM elements the browser paints and the user scrolls. Flutter Web with CanvasKit hands the browser exactly one <canvas> and does everything else itself. That mismatch is the whole story, and once you understand it, you stop chasing scores that don't map to reality and start fixing the two or three things that genuinely matter.

I've shipped enough Flutter Web on Firebase Hosting to have made every one of these mistakes. This post is the mental model I wish I'd had on day one: what each vital is actually measuring on CanvasKit, which levers move the needle, and which popular web-performance advice is pure noise for a Flutter app.

Why Core Web Vitals don't transfer to CanvasKit Flutter Web

The Web Vitals APIs — LargestContentfulPaint, LayoutShift, and Event Timing (PerformanceEventTiming) — are browser observers that watch the DOM. They were built for HTML: <img> tags, <h1> headlines, reflowing paragraphs, buttons the accessibility tree can name. Flutter's CanvasKit renderer paints your entire UI into a single canvas element via WebGL — it's Skia compiled to WebAssembly, drawing pixels directly. The browser has no idea that a "card" appeared, that "text" reflowed, or that a "button" was pressed. From its perspective, pixels changed inside one opaque element it cannot introspect.

This is the defining trait of the CanvasKit rendering model, and it produces three predictable distortions in your Core Web Vitals:

So the honest framing is: two of the three vitals are partly measuring your bootstrap, not your app. Field data (the Chrome User Experience Report, CrUX) still counts them, so they still influence ranking and the numbers real Chrome users send home — you can't wave them away. But you optimize them differently than you would a Next.js or WordPress site.

One clarification worth making early: this analysis is specific to the CanvasKit / Skia renderer. Flutter's older HTML renderer produced real DOM, which made these metrics behave more conventionally, but it's been retired in favor of CanvasKit and the newer WebAssembly path. If you're on modern Flutter stable, you're on a canvas, and everything below applies.

LCP on Flutter Web: it's a loading-sequence problem, not a content problem

On a document site, Largest Contentful Paint is about your largest image or headline. On CanvasKit, LCP is almost entirely time-to-first-meaningful-canvas — which is dominated by downloading and initializing the CanvasKit WASM runtime plus your compiled Dart app before anything paints. The critical-path bytes here are big: the CanvasKit .wasm, the Skia data, main.dart.js (or the WasmGC module), and any fonts. That download-and-init sequence is your LCP.

Here's what you can actually control.

Serve a real first paint from HTML, not a spinner

The element the LCP observer latches onto should be intentional. Put a lightweight, styled splash directly in index.html — real text, a logo as inline SVG, your brand color — so the "largest contentful paint" is a deliberate branded frame, not an accidental loader. This doesn't just game the metric; it's genuinely the first thing the user sees, and it makes the app feel like it booted instantly even while the runtime is still streaming in.

Get CanvasKit and the bootstrap off the critical path early

Preload the runtime so the download starts before Flutter's bootstrap asks for it, and hint the bootstrap module so the parser fetches it in parallel:

<link rel="preload"      href="canvaskit/canvaskit.wasm"      as="fetch" crossorigin="anonymous"><link rel="modulepreload" href="flutter_bootstrap.js">

The goal is to overlap network time with parse and init time instead of doing them in sequence. Every round-trip you can start earlier lands the first real frame sooner.

Self-host the CanvasKit assets

Pull the runtime from your own origin, not a CDN, on first paint. A cross-origin fetch on the critical path adds DNS, TLS, and connection setup you don't need, and you want cache behavior and compression under your control. In your build, point the engine at your self-hosted copy rather than the default remote base URL, and ship the canvaskit/ folder alongside your app.

Compress and cache aggressively

Serve Brotli or gzip and set long-lived, immutable cache headers on the .wasm, main.dart.js, and font assets. Because Flutter's build output is content-hashed, immutable is safe — the filename changes when the content does. On a Firebase Hosting setup like mine, this is a few lines of firebase.json:

{  "hosting": {    "headers": [      {        "source": "**/*.@(js|wasm)",        "headers": [          { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }        ]      }    ]  }}

Firebase applies gzip automatically; if you're on your own Nginx or a CDN edge, enable Brotli for the .wasm and .js payloads specifically — they compress extremely well and this is one of the cheapest LCP wins available.

Trim what you ship

Two build-level habits keep the critical path lean:

The single biggest LCP lever on CanvasKit is not "optimize your images" — it's shrinking and parallelizing the runtime download so the first real frame lands sooner. Treat LCP as a stopwatch on your loading pipeline and optimize the pipeline.

CLS on Flutter Web: don't celebrate the zero, guard the transition

The one place Cumulative Layout Shift is real on Flutter Web is the handoff between your HTML shell and the mounted canvas. If your splash lives in a differently sized container than the canvas Flutter injects, or the canvas resizes as it initializes, you get a genuine, browser-visible shift right at load — and that one is counted honestly.

The fix is to keep the reserved space stable so the box never changes size or position across the handoff:

#flutter-host {  position: fixed;  inset: 0;            /* fill viewport, never reflow */}#splash {  position: absolute;  inset: 0;            /* same box as the canvas will occupy */}

The rule: the box your splash occupies must be exactly the box the canvas takes over, and neither should depend on content height. A couple of edge cases bite here:

Do that and your real CLS stays low honestly — while remembering that every in-canvas "shift" your users feel (a list that jumps when data loads, a dialog that shoves content) is on you to design around, because no metric will ever flag it. The absence of a warning is not the absence of the problem.

INP on Flutter Web: the only vital that measures your app

Interaction to Next Paint is where CanvasKit performance stops being a loading trick and becomes real engineering, because a tap on a Flutter button does register as an interaction, and the "next paint" the browser times is the frame Flutter produces in response. INP reports at roughly the 98th percentile of your interactions, so it's specifically hunting your worst-case responsiveness.

That means INP on Flutter Web is essentially your frame budget under input. The levers are the same ones you'd reach for on mobile.

Crucially, almost none of the classic INP advice transfers. "Code-split your JavaScript," "reduce third-party scripts," "defer non-critical JS" — none of it applies, because you have one app and one main thread doing WASM and rendering. Your INP is won or lost inside your Dart frame, not in the page's script graph. If you take one number from this whole post to obsess over, make it this one.

Measuring Core Web Vitals on Flutter Web honestly

Lab tools will lie to you here, so anchor on field data. Lighthouse and PageSpeed's lab run don't reflect real interaction, real device thermals, or real network variance, and on CanvasKit they'll happily report a "good" LCP that's really just the spinner. Pull real INP, LCP, and CLS from CrUX (via the CrUX API or Search Console's Core Web Vitals report), or wire the web-vitals library into your index.html to capture actual user metrics before Flutter takes over the main thread:

import {onLCP, onINP, onCLS} from 'web-vitals';onLCP(m => navigator.sendBeacon('/vitals', JSON.stringify(m)));onINP(m => navigator.sendBeacon('/vitals', JSON.stringify(m)));onCLS(m => navigator.sendBeacon('/vitals', JSON.stringify(m)));

sendBeacon is the right transport because it survives page unload without blocking navigation. Once the numbers land, read them with the caveats above baked in:

For the INP side specifically, Flutter DevTools' performance and timeline views are far more useful than any web tool, because they show you the actual frame work — build, layout, paint, raster — that the browser is timing as "next paint."

Key takeaways

Core Web Vitals on a CanvasKit Flutter app aren't wrong; they're measuring a different machine than the one they were designed for. Stop trying to make a canvas behave like a document. Fix the loading pipeline, guard the one real transition, and spend the rest of your energy on the frame budget under your users' fingers — because that's the only number they'll ever actually feel.