devShakib

Flutter Web Feels Laggy Even When the Metrics Look Fine: Five Fixes That Worked

Good Lighthouse scores, syrupy scrolling. The five fixes that killed jank in my Flutter web app: BackdropFilter, fonts, a 1.9MB bundle, images, rebuild storms.

Lighthouse gave my site a 96. Largest Contentful Paint was 1.4 s, no layout shift worth mentioning, Time to Interactive comfortably under three seconds on a throttled connection. Every number said the site was fast. Then I opened it on a mid-range Android phone, scrolled the homepage, and it felt like dragging a finger through syrup.

That gap — good scores, bad feel — is the most common complaint about Flutter web, and it isn't a measurement error. Lighthouse measures how fast your page arrives. It has almost nothing to say about how your page behaves once it's there. A frame that takes 40 ms to raster during a scroll is invisible to every load metric ever invented, and it's the only thing the user actually notices.

This post is the five things genuinely wrong with my site — a portfolio serving 114 posts, 98 browser tools and 32 games, all Flutter web on CanvasKit — what each cost in real numbers, and what fixed it. It also covers why CanvasKit's cost model punishes things the DOM hands you for free, how to profile Flutter web instead of guessing, and the part nobody wants to say out loud: there's a fixed multi-megabyte floor you cannot optimise away without changing renderers.

Load performance and interaction jank are two different bugs

Almost every web performance article you've read is about the first. FCP, LCP, TTI, Total Blocking Time — these measure the journey from "user clicked a link" to "user can see and touch something". They're real, they matter for SEO, and they're what Lighthouse scores.

Jank is a different problem with a different unit. Your app has 16.7 ms to produce a frame at 60 Hz, and 8.3 ms at 120 Hz on any recent phone or ProMotion display. Miss the budget and the frame drops; drop several in a row and the user reads it as "cheap" or "not native". Nothing about your LCP changes when that happens.

Worse, Flutter web is partly invisible to the browser's own interaction metrics. Interaction to Next Paint is measured against DOM event handlers — but with CanvasKit your buttons, lists and menus aren't DOM nodes. They're pixels in one <canvas>, and your tap handling happens inside Dart. A perfect INP score on a Flutter web app mostly means the browser couldn't see your interactions, not that they were fast.

So separate the two questions before touching anything. Is the complaint "it takes ages to show up" or "it stutters when I use it"? Those have almost no overlap in cause or fix, and I had both.

Why CanvasKit's cost model isn't the DOM's

In a normal web app you're handed a retained-mode scene graph the browser has spent twenty years optimising. Scrolling runs on the compositor thread. A CSS backdrop-filter gets compositor treatment and often a cached blurred texture. Text layout, font loading, image decode and accessibility are the browser's job — free in the sense that you didn't write them.

CanvasKit throws all of that away. It ships Skia compiled to WebAssembly and paints your entire UI into a single canvas element. The browser has zero knowledge of your widget tree; it can't scroll for you, can't cache a subtree, can't decide that only one region changed. Every frame in which anything changes means re-executing a display list and rasterising it. Scrolling is not a compositor scroll — it's your application repainting sixty times a second.

That inverts a lot of web instinct. Effects that are cheap in CSS because the compositor handles them are expensive here because you handle them. Fonts aren't managed by the browser's font machinery, so a late font is a full relayout of glyphs you drew yourself. And the whole engine has to arrive before the first pixel does.

One note on renderers, because the landscape moved: the old HTML renderer has been retired from recent Flutter versions, so the real choice today is CanvasKit or the WasmGC path (dart2wasm plus skwasm), which needs cross-origin isolation headers to unlock its multi-threaded mode. Check what your channel actually ships before you plan a migration around it. Everything below is CanvasKit, because that's what I run.

Fix one: the glass nav bar was blurring the backdrop every frame

This was the big one, and it was my own design decision. The site has a frosted-glass navigation bar pinned above the content — full width, 64 logical pixels tall, BackdropFilter with a Gaussian sigma of 18. It looked great in a screenshot. In motion it was destroying the raster thread.

Here's what BackdropFilter actually does. It forces a saveLayer, reads back everything already painted behind its bounds, runs a two-pass blur over that texture at the device pixel ratio, then composites the result. Flutter's own documentation flags saveLayer as one of the framework's most expensive operations, and this is saveLayer plus a blur. On a 3× DPR phone my bar meant blurring roughly 1170 × 216 device pixels, every single frame — and because the bar sits above scrolling content, the backdrop changes constantly, so nothing can ever be cached.

Profiled during a scroll on a mid-range Android, raster-thread frames were landing at 18–24 ms. The frame budget is 16.7. That's the syrup.

Three changes fixed it, and the first one did most of the work:

class GlassNav extends StatelessWidget {  /// Driven by a ValueNotifier<bool> off the scroll controller —  /// this flips once at the threshold, not on every scroll pixel.  final bool scrolled;  const GlassNav({super.key, required this.scrolled});  @override  Widget build(BuildContext context) {    final surface = Theme.of(context).colorScheme.surface;    final bar = SizedBox(      height: 64,      child: ColoredBox(        color: scrolled ? surface.withValues(alpha: 0.72) : surface,        child: const NavContents(),      ),    );    if (!scrolled) return bar; // opaque at rest: no filter in the tree at all    return ClipRect(      child: BackdropFilter(        filter: ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8),        child: bar,      ),    );  }}

Render the filter only when there's something behind it worth blurring. At the top of the page the content behind the bar is the hero section, and a solid surface colour honestly looked better. So above the threshold, no BackdropFilter exists in the widget tree.

That last phrase matters. You cannot switch a BackdropFilter off by setting sigma to zero — a zero-sigma blur still allocates the layer and still performs the backdrop readback. The widget has to be absent from the tree, not neutralised.

Second, sigma 18 → 8. Blur cost climbs with radius, and at a 64-pixel bar height nobody could tell in a side-by-side. Third, ClipRect so the filter's bounds are exactly the bar rather than an implicitly larger layer, plus a RepaintBoundary around the scrolling body so the nav's repaint doesn't drag the body's display list with it.

Raster frames during scroll went from 18–24 ms to 4–6 ms. Nothing else in this post produced a change that large.

The honest caveat: if your design genuinely requires live glass over arbitrary moving content at 120 Hz, on the web, you will pay for it. The escape hatches are a pre-blurred static image behind the bar, or a semi-opaque gradient that suggests depth without reading a backdrop pixel. Both are cheating; both look fine.

Fix two: runtime font fetches, FOUT, and one CDN round trip you don't need

The google_fonts package downloads font files at runtime by default. On a normal website a late font is a swap — annoying but contained. On CanvasKit it's worse, because text is glyphs you rasterised: when the real font lands, the engine relayouts and repaints every string on screen. You get a visible reflow, not just a restyle.

The fix is unglamorous. Download the files, subset them, bundle them:

flutter:  fonts:    - family: Inter      fonts:        - asset: assets/fonts/Inter-Regular.ttf        - asset: assets/fonts/Inter-SemiBold.ttf          weight: 600

Bundled fonts are in the asset manifest, so the engine has them before the first frame and there's no swap at all. Subset while you're there — pyftsubset Inter.ttf --unicodes=U+0000-00FF,U+2000-206F takes a general-purpose Latin face down to something you'd want on the critical path. Flutter's --tree-shake-icons (on by default in release) does this automatically for icon fonts, cutting MaterialIcons from over a megabyte to a few kilobytes — but it does nothing for your text fonts. Those are on you.

The other half of this fix is bigger than the fonts. By default a Flutter web build loads CanvasKit from a Google CDN: a third-party DNS lookup, a TLS handshake and a multi-megabyte download on the critical path of your very first paint, from a host whose cache headers you don't control.

flutter build web --release --no-web-resources-cdn

That flag pins CanvasKit into your own build output. It doesn't make the bytes smaller, but it puts them on your origin, behind your own long-lived Cache-Control: immutable, on the same connection that's already open. Combined with a <link rel="preload" as="fetch" crossorigin> for the two faces used above the fold, that removed two cross-origin round trips and about 180 ms from first contentful text.

Fix three: a 1.9 MB bundle that blocked first interaction

main.dart.js was 1.92 MB uncompressed. Everything lived in it: 114 blog routes, 98 tools, 32 games, the admin panel. A visitor who wanted to read one post downloaded, parsed and compiled every game I've ever written.

The tool for this is Dart's deferred loading, which dart2js turns into separate part files fetched on demand:

import 'package:portfolio/games/solitaire.dart' deferred as solitaire;GoRoute(  path: '/games/solitaire',  builder: (_, __) => DeferredPage(    load: solitaire.loadLibrary,          // idempotent, cached after first call    build: () => solitaire.SolitaireScreen(),  ),);

Applied across the tools and games, main.dart.js went 1.92 MB → 1.08 MB, roughly 310 KB over the wire with Brotli, the remainder split into about forty part files pulled on navigation. First interaction on the blog routes improved by more than a second on a throttled connection, because the browser stopped compiling code for pages nobody asked for.

Three things break deferred loading, and all three bit me:

One refinement worth the ten lines: call loadLibrary() on hover or link intent, not on navigation. It's idempotent and cached, so prefetching a chunk while the cursor is still travelling hides the fetch entirely. A deferred route that feels instant is indistinguishable from one that was never split.

Fix four: images decoded at full size and served uncached

Two separate problems live under "images are slow", and they need different fixes.

The wire problem was ordinary: post covers were 1600-pixel JPEGs at roughly 400 KB each, a dozen on the blog index. Converting to WebP at the width they're actually rendered at took each to about 38 KB, and the index's image payload from 2.1 MB to 240 KB. AVIF is smaller still, but encode time and older-Safari history make WebP the safe default for a site that isn't image-led.

The decode problem is CanvasKit-specific and much less obvious. Image.network decodes at the source resolution regardless of how big the widget is. A 2400 × 1600 photo rendered into a 320-pixel card allocates a full-size GPU texture and burns decode time producing pixels you throw away. cacheWidth fixes it:

Image.network(  url,  width: 320,  height: 180,  fit: BoxFit.cover,  cacheWidth: (320 * MediaQuery.devicePixelRatioOf(context)).round(),)

Give every image an explicit width and height as well — not for the framework's benefit, but so the layout doesn't reflow when the bytes land. And precacheImage the two or three covers that are above the fold in a post-frame callback, so they're decoded before the user can scroll to them.

Then cache them properly. Hashed image paths get Cache-Control: public, max-age=31536000, immutable on Firebase Hosting, so a repeat visitor fetches zero image bytes. That's a performance win and, on a Hosting plan with a daily transfer quota, a cost win too.

Fix five: rebuild storms from StreamBuilders near the root

The last one was invisible in every load metric and obvious the moment I looked at a build profile. A StreamBuilder on the Firestore posts collection sat above the Scaffold, near the root of the route, and every emission rebuilt the entire subtree beneath it. On mobile that's wasteful; on CanvasKit it's worse, because a rebuild that changes anything means re-recording the display list and repainting the canvas.

And it emitted far more often than I thought. Firestore fires twice for a local write — once optimistically with hasPendingWrites, once on server confirmation. Then there's the classic:

// Wrong: a new stream object on every build.// Rebuild → resubscribe → emit → rebuild → forever.StreamBuilder(stream: db.collection('posts').snapshots(), builder: ...)

The stream is constructed inline, so each rebuild produces a new object identity, StreamBuilder resubscribes, and the fresh subscription emits immediately. On Firestore that's also a full billed re-read of the collection each time round.

What fixed it:

Build time on the index route during scroll went from about 11 ms per frame to 1.8 ms. Combined with the nav fix, the same scroll that used to blow the budget on both threads now sits comfortably inside it.

Profiling, and the ceiling you can't profile away

Profile with a timeline, not a vibe

Every fix above came from a trace. None came from intuition, and two contradicted what I'd have guessed.

Never profile a debug build. Debug web is compiled for fast reload, not fast execution, and it's easily an order of magnitude slower than release. Any conclusion you draw from it is a conclusion about the compiler. Use flutter run -d chrome --profile, or build release and serve the output.

The first question is always: is the time in Dart or in Skia? Build and layout costs show up as long Dart tasks on the main thread; raster costs show up as canvas and GPU work with almost nothing Dart on the stack. Rebuild storms are the first kind; blurs, big saveLayers and oversized textures are the second. Applying a rebuild fix to a raster problem is how a week disappears, and the trace tells you which you have in ten seconds.

The practical loop I use:

canvaskit.wasm is a tax, not a bug

Here's the thing none of the five fixes touched. CanvasKit is roughly 2.25 MB before compression, and it's on the critical path of the very first load. You can Brotli it, self-host it, cache it for a year — you cannot delete it, and no amount of Dart-side optimisation moves it.

In practice that's a lopsided experience. A repeat visitor with a warm cache gets an app that opens instantly and runs at full frame rate. A first-time visitor on a cold cache and a slow connection waits for a payload a static HTML page would never have needed. That asymmetry is structural.

Three honest options. Accept it, which is reasonable for anything app-shaped where users return. Move to the WasmGC path, which changes the trade-off but brings cross-origin isolation requirements and browser-support caveats. Or don't build that surface in Flutter at all — a blog index is 40 KB of HTML in any static site generator, and 40 KB beats 2.25 MB in every network condition that exists.

I stayed on CanvasKit for one reason: those 98 tools share Dart code with mobile apps. One implementation, three platforms, one set of bugs. That's worth a fixed 2.25 MB to me. It would not be worth it to a marketing site. Pick the renderer that fits the surface, not the framework you're already comfortable in. If your site is mostly text, Flutter web is the wrong tool and no amount of profiling changes that.

Key takeaways

The uncomfortable summary is that Flutter web performance work is mostly unlearning web performance work. The instincts that serve you on the DOM — lean on the compositor, let the browser handle text and images — either don't apply or actively mislead, because with CanvasKit you are the browser. Once that clicked the fixes were obvious and mostly small: stop blurring what nobody's looking at, ship your fonts, split your bundle, decode images at the size you'll draw them, and don't rebuild the world when one number changes. The 2.25 MB floor stays where it is. Everything above it is yours.