I shipped a Flutter Web app to production in March 2026, four days after the Skia deprecation finally landed in the stable channel. The app is a B2B dashboard for logistics — 14 screens, 47 widgets, real-time maps, and a CSV exporter that does not have any business being as slow as it was. The product team wanted "one codebase, three platforms" and the mobile clients were already in Flutter. Web was the missing piece.
Here is what I learned in the six months since. No marketing, no roadmap promises, just the version that actually shipped.
The Skia Deprecation Was a Bigger Deal Than People Admit
When the Flutter team announced that Skia was going away in favor of Impeller on every platform, including web, the response on Hacker News and Reddit was somewhere between "fine" and "I told you so." The Flutter team had been pushing Impeller for mobile for a year and a half at that point. Most teams had migrated on iOS and Android already. The assumption was that web would be the easy one — same engine, different canvas, what could go wrong.
What could go wrong, it turns out, was everything around it.
The actual Impeller-on-web story is more nuanced than the docs suggest. The docs say "Impeller is the default renderer for Flutter 4.0 on all platforms." True. What the docs do not say is that the Impeller-web backend uses WebGPU when available and falls back to a CanvasKit-style path otherwise. The CanvasKit path is essentially the old Skia path with a new name, and it works fine. The WebGPU path is faster, smoother, and uses about 30% less memory in my testing — when it is available. In August 2026, WebGPU is enabled by default in Chrome 138+ and Edge 138+, behind a flag in Safari 18.4, and not present at all in Firefox stable. So in practice, you ship two render paths and pick one at runtime, and the moment you say "WebGPU first" you are betting on Chrome dominance holding for the next 18 months.
Here is the renderer detection code I ended up with, after three rounds of "it works on my machine" reports from QA:
import 'dart:js_interop';
@JS()
external JSAny? get navigator;
bool _detectWebGPU() {
try {
final nav = navigator;
if (nav == null) return false;
return (nav as JSObject).has('gpu');
} catch (_) {
return false;
}
}
class WebGpuDetector {
static bool? _cached;
static bool get isAvailable {
return _cached ??= _detectWebGPU();
}
}
Then in the app shell, before the first frame, we log which path was taken so the support team can answer "why is this slower on Safari" tickets in one minute instead of thirty.
The thing nobody tells you about the Skia-to-Impeller migration on web is that the font rasterization path changed. Fonts that looked crisp on Skia will look slightly different — not wrong, just different — on Impeller. We caught it on the dashboard's KPI numbers. The Inter font at 14px rendered with sub-pixel hints on Skia and snapped to pixel grid on Impeller. The numbers shifted by one pixel vertically. Tiny, but the design team noticed and we had to bump the line-height by 0.5. That is the kind of migration tax nobody puts in a release note.
WASM Is the Default Now (and That's Mostly Good News)
The other big change is that the WASM build is no longer opt-in. In Flutter 3.27 you had to add --wasm to the build command and pray. In Flutter 4.0 the new flutter build web --wasm is the recommended path and the team has dropped the JS-only build from the default scaffolding. You can still ship JS-only with --no-wasm, but the docs steer you away from it.
The numbers in production, for a typical 14-screen app at my company:
- JS build: 1.8MB compressed, first contentful paint at 1.9s on cable, 4.2s on 4G.
- WASM build: 1.4MB compressed, first contentful paint at 1.3s on cable, 2.8s on 4G.
That is roughly a 30% size reduction and a 30% time-to-interactive reduction, on the same hardware, on the same network. The trade-off is build time — the WASM build is about 2.4x slower than the JS build. For us, 8 minutes becomes 19 minutes, which is fine in CI but annoying locally.
The other trade-off is that the WASM build needs a server that sets Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp to enable SharedArrayBuffer. If you cannot set those headers — and I have worked at companies where the CDN team will not, or cannot, or "will get to it next quarter" — the WASM build will silently fall back to a slower path that does not use threads. The 30% improvement evaporates and you get the same speed as JS but with a 2.4x slower build.
Here is the nginx snippet that we had to argue for three months to land:
location /app/ {
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
types {
application/wasm wasm;
}
}
If you are deploying to Cloudflare Pages or Vercel, both support those headers via _headers files. If you are deploying to a corporate IIS or Apache setup, prepare for a long email thread.
SEO Is Still the Hard Part
The honest version of the SEO situation in 2026 is: it works, but only if you stop trying to be a SPA.
Google has indexed JavaScript SPAs for a decade now, and the indexing pipeline is faster than it used to be. But "indexed" and "ranked for a long-tail query" are different things. A Flutter Web app that renders entirely client-side will get crawled eventually, but you will lose the meta descriptions, the OG tags, and the structured data that come from server-rendered HTML. If your business depends on organic traffic, you already know this. If you are a developer who is being told "we just need the app to rank for 'logistics dashboard,'" you need to push back on the architecture before you ship.
There are three paths here, in order of how much work they are:
The marketing site is HTML, the app is Flutter Web, they live in different URLs. This is the path we picked. Marketing pages are Astro, the app is Flutter Web at
/app/. The two do not share state. A user clicking "Launch Dashboard" from a landing page gets a fresh Flutter Web load. Annoying, but the marketing team can iterate on their own stack and the Flutter team can iterate on theirs. No shared concerns.The whole site is HTML, the Flutter app is embedded as a widget for logged-in users only. This is what most consumer apps do. You build a normal HTML/CSS/JS marketing site, the user signs in, and the "app" portion is a Flutter Web widget on
/dashboard/. You give up deep linking and you have to deal with auth handoff, but SEO is trivial.You use a server-side renderer for Flutter Web. This existed briefly as an experiment and has been quietly deprecated. The Flutter team pointed everyone at the WASM build instead. I would not bet on SSR returning.
The path you do not want to take is "let's make the entire 14-screen app crawlable." It is technically possible, it is a lot of work, and the result will be worse than path 1 or 2 in every dimension. I have seen two teams go down this road. Neither shipped.
If you do pick path 1, the one trick that made the launch smoother was to render the app shell on the server with the same HTTP caching headers as a static file:
location /app/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
location ~* \.(html)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
}
The HTML is never cached, the WASM and the assets behind it are cached for a year with a content hash in the filename. Standard pattern, but the trick is to make sure every asset has a content hash in its filename, which the Flutter build does by default. If you have any custom assets, run them through the build's hashing step or you will have cache-busting issues on every deploy.
Performance Numbers From a Real Production Deployment
The dashboard I shipped has four screens that I would consider "real" performance tests:
- A map with 1,200 markers, refreshed every 30 seconds via WebSocket.
- A time-series chart with 24 hours of data, 86,400 points.
- A table with 5,000 rows, sortable, with column virtualization.
- A form with 47 fields and 3 dependent dropdowns.
Here is the median frame time on a 2023 M2 MacBook Air, Chrome 139, after a warm load, on the WASM build:
- Map: 8.2ms (60fps cap is 16.7ms — well under).
- Chart: 12.4ms.
- Table: 6.1ms.
- Form: 3.8ms.
On the same machine, on the JS build:
- Map: 14.8ms (still 60fps but barely).
- Chart: 21.3ms (drops to 47fps on pan/zoom).
- Table: 9.7ms.
- Form: 5.2ms.
So WASM is a real win, and the chart screen is the one that needs it. The form is fine either way.
On a 2020 Intel i5 laptop with integrated graphics, the numbers are worse across the board, and the gap is bigger:
- Chart, WASM: 22.1ms.
- Chart, JS: 38.4ms.
That is the difference between "feels slow but usable" and "feels broken." If your user base is corporate laptops that are three to five years old, WASM is not optional. If your user base is brand-new MacBook Pros, you can ship either.
The other number worth knowing is memory. The WASM build holds steady at about 240MB for the dashboard. The JS build creeps from 180MB to 320MB over a 20-minute session because the JS garbage collector is less aggressive than the WASM one. On long sessions, that 80MB delta becomes "Chrome killed the tab because the user has 12 other tabs open." We have not figured out how to keep users from running 12 tabs, but the WASM build has reduced support tickets about "the dashboard disappeared" by about 60%.
Bundle Size, Caching, and CDN Gotchas
The 1.4MB compressed figure from earlier is the entire WASM bundle plus assets, served from a single CDN. Here is how it breaks down for our app:
main.dart.wasm: 1.1MB compressed, 3.4MB uncompressed.flutter.js(the JS glue): 18KB.assets/AssetManifest.json: 4KB.assets/FontManifest.json: 1KB.assets/fonts/Inter-*.ttf: 280KB total, 4 weights.assets/images/*.webp: 80KB total.canvaskit.wasm: 220KB, only loaded if the user is on Firefox or a pre-138 Chrome.
You can shave the WASM binary by another 15% if you enable deferred loading and lazy-import every screen. We did this for the second release and the time-to-interactive on the login page dropped from 1.3s to 0.9s. The trade-off is a visible "loading" spinner on the screen the user navigates to, which the design team tolerated. If you cannot tolerate it, you are stuck with the monolithic bundle.
The caching strategy that worked for us was, predictably, the boring one:
main.dart.wasm—Cache-Control: public, max-age=31536000, immutable. Content hash in the URL. Cache forever.flutter.js— same. Forever.index.html—Cache-Control: no-cache. Always revalidate. The HTML is the only thing that knows about the currentmain.dart.wasmhash.- Service worker — disabled. We tried. It made deploys harder and the support team could not figure out how to invalidate it. Killed after two weeks.
If you have a service worker in production and your deploy process does not include a "force unregister" step, that is a future incident. Just delete it.
On the security side, Content Security Policy on Flutter Web is mostly fine until you try to do something useful, and then it is a series of one-off exceptions.
The default CSP that ships in the Flutter Web starter is wide open. script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'. The 'unsafe-eval' is there because the Dart-to-JS compiler emits eval() for some hot paths in 4.0. It is not in the WASM build, so if you commit to WASM-only you can drop it. Drop it. The 4% of users on browsers that do not support WASM can use the older app version while you figure out a migration plan.
The 'unsafe-inline' for styles is because the Flutter framework injects styles inline. This one is harder to work around. We settled on style-src 'self' 'unsafe-inline' and accepted it, because the alternative was rewriting half the framework. If you have a security team that requires nonce-based styles, Flutter Web in 2026 is not ready for you.
For images and fonts, the standard img-src 'self' data:; font-src 'self' data:; works.
The other CSP gotcha is the WebAssembly MIME type. Some CDNs default to application/octet-stream for .wasm files, which makes the browser refuse to instantiate them. The fix is one line in your CDN config:
application/wasm wasm
Cloudflare Pages gets this right by default. AWS CloudFront does not. If you are on CloudFront, add a response content type override or you will spend an afternoon debugging "why is the WASM build slow on Firefox" and the answer will be "Firefox is rejecting the module because the MIME is wrong."
When Flutter Web Is the Wrong Choice
I have been cheerleading for Flutter Web this whole article, so let me be honest about the cases where you should not pick it.
If your app is a content site or a marketing site, do not use Flutter Web. Use Astro, Next, Eleventy, or whatever static site generator your team knows. SEO will be easier, the bundle will be smaller, and you can hire a frontend dev who already knows React. Flutter Web for a content site is the equivalent of using a sledgehammer to hang a picture frame.
If your app is a single-screen tool that does one thing, do not use Flutter Web. If you are building a CSV-to-chart converter, a regex playground, or a single-page calculator, the Flutter runtime is overkill. The WASM build is 1.4MB to render 400 lines of UI. Build it in HTML and a tiny JS file. You will ship in a day instead of a week.
If you need a pixel-perfect design system that the design team owns in Figma, do not use Flutter Web. The widget system in Flutter is opinionated, and Impeller renders text slightly differently from the browser's native text rendering. The 1px difference that the design team did not notice on mobile will be a Slack thread on web. Either accept the difference or use a web framework.
If your team has zero Flutter experience and a deadline, do not use Flutter Web. Flutter is a great framework but the learning curve is real. The first screen takes a week. The 14th screen takes an hour. If you have a deadline and no Flutter experience, you are going to spend the entire deadline on the learning curve.
If you need deep integration with the browser — IndexedDB at scale, Service Workers, WebRTC, WebSockets with custom framing, Web Bluetooth — be careful. Flutter Web supports all of these, but the "supported" is at varying levels of stability, and the moment you go off the happy path you are reading the engine source code and filing issues. It works, but it is not as boring as doing it in plain TypeScript.
The cases where Flutter Web in 2026 is the right call are the cases where it was the right call in 2024: you have a mobile app in Flutter, you need a web client, the screens are mostly forms and lists, and the design team can work within Material Design or Cupertino. The WASM build and the Impeller renderer are real improvements over the 3.x experience. The migration tax is real but it is paid once, and the result is a web client that ships from the same repo as your mobile apps with no parallel implementation.
The Honest Recap
After six months, here is the version I would give a friend who is starting a Flutter Web project today:
Ship WASM. Do not ship the JS build. Set the COOP/COEP headers or the WASM build will be wasted. Use path 1 or path 2 for SEO — never try to make a 14-screen SPA crawlable. Expect 19-minute CI builds. Budget 2-3 weeks of "fix the migration tax" work after the upgrade, mostly fonts and tiny visual differences. Disable the service worker. Watch the MIME type on the CDN. Accept that the chart screen with 86K points is the one that needs WASM the most.
That is the job. It is not glamorous. The web is faster than it was, the bundle is smaller than it was, and the migration was a real tax. I would do it again for this app. I would not do it for the next content site, and neither should you.
Comments
Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.