Run flutter create --platforms=web,macos,windows,linux . in the project root. It adds one folder per platform with that platform's host project and leaves lib/ alone; those folders are source you own and commit.
**web/**: index.html, manifest.json, favicon.png and icons/: the place for meta tags, a loading skeleton and a custom flutter_bootstrap.js
**windows/, macos/, linux/: a native runner per OS (C++ on Win32, a Swift Xcode project, C++ on GTK); the initial window size and title live there
Desktop builds only on its own OS**: flutter build macos needs a Mac with Xcode, Windows needs Visual Studio with the C++ workload, Linux needs clang, CMake, Ninja and the GTK headers; there is no cross-compiling
Check the toolchain: flutter doctor and flutter devices, then flutter run -d chrome or -d macos
FlutterSenior
Flutter on Web & Desktop
Web renderers and wasm, deferred loading, PWA, JS interop, desktop windowing, plugin gaps
Questions closed
0 / 20
- 0Review
- 3Asked often
Questions
Flutter web paints the whole UI into a canvas, so it fits app-like products behind a login (dashboards, internal tools, editors, a web companion to a mobile app) and fits badly where a page must load instantly, be indexed or behave like a document.
First load: every new visitor downloads a renderer of a few megabytes before the first frame; it is cached afterwards, so it hurts landing pages far more than daily-use tools
SEO and link previews: content is pixels, not DOM text, and every route is served the same index.html, so crawlers see little and every shared link previews the same title
Browser behaviour is re-implemented: text is selectable only inside SelectionArea, Ctrl+F finds nothing, and scrolling, autofill and context menus feel slightly unlike the browser's own
Accessibility is opt-in: the semantics tree is off by default on the web until a screen reader user activates it or you call SemanticsBinding.instance.ensureSemantics()
**No dart:io, fewer plugins**: no filesystem or raw sockets, and every plugin needs a web implementation
The usual split: marketing pages, docs and the blog in HTML, the product itself in Flutter.
Two, both Skia compiled to WebAssembly: CanvasKit for the default JavaScript build and skwasm for the --wasm build. The DOM-based HTML renderer was deprecated in Flutter 3.27 and removed in 3.29, so there is no lightweight fallback any more.
CanvasKit: Skia plus JS glue, a couple of megabytes of wasm, runs in every modern browser and rasterizes on the main thread next to your Dart code
skwasm: a Skia build made for the dart2wasm app; with cross-origin isolation it rasterizes on a web worker, so frames stop competing with your Dart. It is used only where the browser can run the wasm build; elsewhere the loader serves JS with CanvasKit
Where the engine comes from: Google's CDN (gstatic.com) by default; --no-web-resources-cdn serves it from your own origin
The price is per first visit: the engine is most of an empty build's size, then it sits in the HTTP cache
Same pixels everywhere: identical output on every browser is the point, and also why text is not DOM text
--web-renderer is gone: you choose by building with or without --wasm.
Because --wasm is opt-in, and even then the output holds a JavaScript build beside the wasm one: flutter.js picks per browser, so every user whose browser cannot run the wasm build still gets JS.
What wasm buys: dart2wasm code runs faster than dart2js output, and skwasm can render on a worker; the gain shows in frame times more than in download size
It needs WasmGC: Chromium 119+ runs Flutter's wasm build; Firefox and Safari have WasmGC but known browser bugs block it, and iOS browsers (all WebKit) cannot run it, so they get the JS build; check the docs for current state
Threads need two headers: Cross-Origin-Embedder-Policy: credentialless (or require-corp) and Cross-Origin-Opener-Policy: same-origin on the page; without them you silently lose the render worker
Those headers have a cost: they change how cross-origin iframes and credentialed resources load, so test third-party embeds and auth popups
Interop is stricter: dart:html, dart:js and package:js do not compile to wasm, so one transitive dependency on them blocks the build; recent flutter build web runs a wasm dry run and lists them
Treat wasm as a progressive enhancement: clean the dependency tree, set the headers, measure, keep JS as the floor.
The HTML loads, flutter_bootstrap.js picks a build, main.dart.js and the renderer download and compile, the engine initialises, and only then does runApp paint. Everything you await in main() before runApp is added on top.
The sequence: index.html runs flutter_bootstrap.js; _flutter.loader.load() fetches the entrypoint and CanvasKit or skwasm; initializeEngine() boots the renderer and fonts; runApp() creates the view and starts your widgets
Transport first: your own origin, HTTP/2 or HTTP/3, brotli or gzip on .js and .wasm, <link rel="preload"> for the big files, long caching for versioned engine files
Paint something in HTML: a skeleton in index.html (markup and CSS, not a spinner GIF), removed on the flutter-first-frame event
**Never await before runApp: Firebase init, remote config and storage reads start after the first frame, behind a splash widget
Ship less Dart: deferred imports for big, rarely used features; bundle your fonts, because missing glyphs make the engine fetch fallback fonts from Google's servers
Measure in the browser**: Lighthouse and the Network and Performance panels; Flutter DevTools sees nothing before the engine boots
dart2js moves a library into its own part file only when every path to it is deferred. Here routes.dart imports admin_page.dart eagerly, so AdminPage and everything it reaches stay in the main bundle and the deferred import splits nothing.
Loading units are computed over the whole program: code reachable from both the eager entrypoint and a deferred import stays in the main unit, which is why a 400 KB feature often frees only a fraction of that
The usual leaks: a route table naming the page type, a barrel file re-exported from eager code, a DI container registering the type at startup
Verify in bytes: a working split leaves main.dart.js_1.part.js and friends in build/web; no part files means nothing was deferred
**Use it only after loadLibrary()**: touching admin.AdminPage before the future completes throws at run time
**loadLibrary() can fail: offline, or a deploy landing between page load and the click serves a part from another build; give it a real error state, not an endless spinner
Not the same feature as deferred-components**: that pubspec.yaml section is Android Play Feature Delivery and does nothing on the web
index.html resolves every relative URL against its <base href>, which defaults to /, so the app requests /main.dart.js instead of /my-app/main.dart.js. Build with --base-href /my-app/; the value must start and end with a slash.
What the flag rewrites: the $FLUTTER_BASE_HREF placeholder in web/index.html; a hard-coded <base href="/"> there is left as it is, so keep the placeholder
Routes stay root-relative: the path URL strategy strips the base, so /settings in your router is /my-app/settings in the address bar; never repeat the prefix in route paths
Deep links need a fallback: with usePathUrlStrategy() a reload of /my-app/settings must return /my-app/index.html; GitHub Pages has no rewrites, so ship a copy of index.html as 404.html, or keep hash URLs
Absolute paths you write yourself skip the base: a /api/... call or a /assets/... link in hand-written HTML still points at the domain root
Local runs hide it: flutter run -d chrome serves from /, so test by serving build/web under the same prefix
Nothing from inside the Flutter app. The content is drawn into a canvas and every route is served the same static index.html, so crawlers find little text and link scrapers read one title for every URL.
Text is pixels: Googlebot runs JavaScript and renders the shell, but there is no DOM text, no heading structure and no per-route <title> worth indexing
Link scrapers run no JavaScript: Slack, Telegram, WhatsApp and X read the og: tags straight from the HTML, so a Title widget that sets document.title at run time never reaches them
Best fix: take documents out of Flutter: landing, pricing, docs and blog as static or server-rendered HTML; Flutter for the app behind the login
Next best: write the head per route at the edge: a pre-generated copy of index.html per public route with its own <title> and og: tags, or an edge function that rewrites the head on request; the same Flutter app boots from each copy
Either way: path URLs (a # fragment never reaches the server, so per-route HTML is impossible), a sitemap.xml, and verify with curl -A Twitterbot, not your browser
The generated manifest.json and icons already make it installable; offline caching and updates are yours. Flutter's own caching service worker is deprecated, so which build a user runs is decided by your HTTP cache headers and whatever worker you ship.
Installable: HTTPS plus web/manifest.json (name, icons, start_url, display: standalone); Chromium offers an install button, iOS uses Add to Home Screen
Why users stay on yesterday's build: index.html, flutter_bootstrap.js and main.dart.js keep the same names on every deploy; with a long max-age the browser does not even ask the server
Fix the headers: Cache-Control: no-cache on those entrypoints (revalidate every time, a 304 is cheap). Flutter does not fingerprint file names, so long caching is safe only for files you version yourself; the engine on Google's CDN is already versioned by revision
Old workers outlive the deploy: a service worker from an older Flutter build keeps serving its cached shell; ship a self-unregistering worker at the same URL to retire it
Offline is opt-in: your own worker (Workbox is the usual choice) with an explicit update flow: detect the waiting worker, show "New version, reload", then skipWaiting()
The browser blocked the response before Dart could see it (almost always CORS, sometimes mixed content). There is no status code because your code never received a response, and no Dart change fixes it: the server has to allow your origin.
Why only the web: a mobile app is not bound by the same-origin policy; a browser adds an Origin header and checks the response before handing it over
Preflight: a JSON body or an Authorization header makes the request non-simple, so the browser first sends OPTIONS, and the server must answer it with a 2xx
What the server must send: Access-Control-Allow-Origin with your exact origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers: authorization, content-type; with cookies also Allow-Credentials: true, which forbids the * origin
Read the Console, not the exception: the browser prints the real reason (missing header, failed preflight, an http:// call from an https:// page), and the Network tab shows the OPTIONS request
Local development: flutter run -d chrome picks a random port; pin it with --web-port and allow that origin, or proxy the API. --disable-web-security only hides the bug
dart:html, dart:js, dart:js_util and package:js are the legacy interop and do not exist for dart2wasm. The DOM now comes from package:web, and everything else from dart:js_interop; both work under dart2js and dart2wasm.
**package:web**: typed bindings generated from the browser's Web IDL: web.document.querySelector('#root'), web.window.localStorage. Names follow the spec, so migration is mostly renaming
**dart:js_interop**: types for JS values (JSObject, JSString, JSPromise, JSFunction); you describe an SDK with external members on an extension type over JSObject, which allocates no wrapper at run time
Conversions are explicit: 'x'.toJS, jsString.toDart, await promise.toDart, callback.toJS, and jsify() / dartify() for loose data; setProperty / getProperty / callMethod on a JSObject live in dart:js_interop_unsafe
Validate at the boundary: declared types are not checked, so an external getter typed String can hand you undefined after an SDK update; is and as on JS types behave differently per compiler, so use value.isA<JSArray>()
The condition asks whether the compilation target has dart:html, and dart2wasm does not have it. The third branch is never chosen, the stub compiles cleanly, and nothing fails: the token simply reads as null.
**dart.library.X asks what exists**: under dart2js both dart.library.html and dart.library.js_interop are true; under dart2wasm only js_interop; on mobile and desktop only io
The fix is one word: if (dart.library.js_interop), which is true for both web compilers; storage_web.dart already uses package:web, so it compiles under both
Why it was silent: a stub that returns a harmless default compiles everywhere; make it throw UnsupportedError so a wrong branch fails in the first test instead of in production
Search your dependencies too: a package with a dart.library.html condition has the same bug and needs an update before you ship --wasm
Platform comes from dart:io, which the web compilers accept but do not implement: Platform.isMacOS throws Unsupported operation: Platform._operatingSystem inside build, and a release build paints the grey ErrorWidget instead of the toolbar.
Why CI stays green: importing dart:io is not a compile error for dart2js or dart2wasm, so flutter build web succeeds; the stub throws only when a getter runs, which first happens on a real frame in a browser
The fix: defaultTargetPlatform from package:flutter/foundation.dart works on every target; on web it reports the OS the browser runs on, so Chrome on Windows gets Ctrl+S and Safari on a Mac gets Cmd+S
**kIsWeb vs defaultTargetPlatform**: kIsWeb answers "is this the browser build" (download a file instead of writing it); defaultTargetPlatform answers "which platform conventions apply" (shortcuts, scroll physics, layout). When you truly need a dart:io API, check !kIsWeb first or move the code behind a conditional import
Easier to test: debugDefaultTargetPlatformOverride lets one widget test cover both the desktop and the mobile branch; Platform.isMacOS cannot be overridden
Catch it next time: run widget tests with flutter test --platform chrome and smoke-test the web build in CI; treat any dart:io import in a widget shared with web as a review flag
In the browser your Dart runs as JavaScript or wasm on the page's main thread, and with CanvasKit the rasterizing happens there too, so build, layout, paint and raster share one thread that mobile splits in two.
Why it is slower
One thread: CanvasKit rasterizes on the main thread; skwasm with cross-origin isolation moves that to a worker, often the largest single win
No native AOT: dart2js output is slower than compiled ARM code; the wasm build closes much of the gap
Platform views are expensive: each HtmlElementView (a map, a video, an iframe) splits the scene into extra canvases around the DOM element; two on a screen are fine, one per list row is not
How to measure
Never judge debug mode: flutter run -d chrome uses the unoptimised dev compiler; profile with --profile or a release build
Use each tool for its job: Chrome's Performance panel on a profile or release build for scripting, GC and raster time; DevTools in debug only to find which widgets rebuild or repaint, not to read timings
What to fix
The usual rules, a tighter budget: fewer rebuilds, const widgets, RepaintBoundary around animations, and no saveLayer sources (Opacity, ShaderMask, BackdropFilter) inside scrolling content
Images: decode at display size with cacheWidth; cross-origin images need CORS headers before CanvasKit can read their pixels
Fonts: bundle every font and script you render; a missing glyph makes the engine download fallback fonts in the middle of a scroll
**Ship --wasm with the headers** where your audience is on Chromium
Flutter web mounts into whatever element you pass the loader as hostElement, and the two sides exchange data only through explicit JS interop: no shared state, router or event bus.
**Mount with hostElement**: _flutter.loader.load({config: {hostElement: el}}) in your own web/flutter_bootstrap.js. Without it Flutter takes over the whole <body>. The element needs a real height, or Flutter fills nothing and it looks like a failed load
JS to Dart: install a Dart closure on globalContext at startup and push what arrives into a ValueNotifier
Dart to JS: dispatch a CustomEvent on the host element so the page listens as it would to any component, or call an external function the host declared; convert at the edge and keep domain objects on the Dart side
One engine per app: two separate mounts boot the renderer twice; for several regions on one page use multi-view mode (multiViewEnabled: true, then runWidget instead of runApp); with app.addView({hostElement, initialData}) each view gets its own initial data
**Call setUrlStrategy(null)**: otherwise the embedded app writes to the address bar and fights the host's router
Every .toJS call wraps the tear-off in a new JS function, so removeEventListener gets a function the window never saw and removes nothing. Each closed screen leaves its listener behind: a message runs once per screen ever opened, and dead States call setState.
Why it looks right: in Dart two tear-offs of the same method on the same object are ==, which is why removeListener(_onTick) works on a ChangeNotifier; the browser compares listeners by JS identity, and toJS is documented to return a new function on every call
The fix: convert once and keep the result: a late final web.EventListener _listener = _onMessage.toJS field passed to both addEventListener and removeEventListener
Alternative: web.EventStreamProviders.messageEvent.forTarget(web.window).listen(...) returns a StreamSubscription; cancel it in dispose and there is no JS function identity to get wrong
It also leaks memory: the JS function holds the Dart closure, which holds the State and its subtree, so every closed screen stays reachable from window
Catch it next time: a test that mounts and unmounts the widget three times, dispatches one MessageEvent and expects one handler call; in Chrome DevTools, getEventListeners(window) shows the list growing
There is no filesystem on the web. dart:io and path_provider do not work there, so a save is a download and every read starts from a file the user handed you through a picker or a drop.
Reading: file_selector's openFile returns an XFile everywhere, and readAsBytes() is the portable call. XFile.path is a real path on desktop and a blob: URL on the web, so code that opens the path breaks
Writing on desktop: getSaveLocation() for the path, then dart:io in a file behind a conditional import
Writing on the web: build a Blob and click a synthetic <a download>; showSaveFilePicker gives a real save dialog, but only in Chromium and only in a secure context
User activation: the save picker and clipboard reads need a recent user gesture, so a long await before calling them makes the browser refuse
The clipboard is asymmetric: Clipboard.setData works everywhere; getData needs a permission or a paste prompt on the web
macOS ships with App Sandbox on: add com.apple.security.files.user-selected.read-write to both entitlement files, and store a security-scoped bookmark to reopen that file next launch
Audit platform support as data before the first web build, because a missing implementation shows up three ways (a failed build, a MissingPluginException on the first call, or a silent no-op), and only the first one is loud.
Read the declared platforms: for each plugin in flutter pub deps, check the platform tags on pub.dev, which come from flutter: plugin: platforms: in its pubspec; no web entry means no web implementation
Endorsed or not: in a federated plugin, foo_web registers itself only if foo endorses it; an unendorsed community implementation has to go into your own pubspec.yaml, or you get MissingPluginException
Compile-time gaps are the cheap kind: a dependency that imports dart:ffi, or dart:html/package:js under --wasm, fails the build; dart:io compiles on web and throws UnsupportedError only when the code runs, so it is a runtime gap. Build every target in CI on every pull request and the loud ones show up on day one
Runtime gaps need execution: integration tests in Chrome (flutter drive -d chrome with chromedriver) that open every screen calling a plugin; a green build proves nothing about a channel with no receiver
**Degrade in the tree, not in a catch**: a facade per shaky capability with bool get isSupported, so the screen renders the alternative instead of a button that throws
Decide per gap: a web-capable alternative package, your own web implementation over package:web, or the feature hidden on the web
Very little of the window is Dart. The first window is created by the native runner in your own repo, menus and exit handling are framework APIs, and changing the window at run time needs a package.
The runner owns the first frame: initial size and title live in windows/runner/main.cpp, macos/Runner/MainFlutterWindow.swift and linux/runner/my_application.cc. They are your source files, so edit them; resizing from Dart after start-up gives a visible jump
**PlatformMenuBar**: builds a real system menu bar, natively supported on macOS; for an in-window menu on Windows and Linux use Material's MenuBar with SubmenuButton and MenuItemButton
**AppLifecycleListener(onExitRequested:)**: receives Cmd+Q, Alt+F4 and closing the last window; return AppExitResponse.cancel, ask about unsaved work, then decide
Still a package: window_manager for runtime size, minimum size, always-on-top and close interception; tray_manager for the tray; screen_retriever for displays and scale factors
Several real OS windows: the framework's multi-window API is still experimental; shipping apps use desktop_multi_window, where each window runs its own engine and isolate, so windows share no Dart state
Sign and package per OS: a signed MSIX or installer on Windows, Developer ID signing plus notarization on macOS, a bundle built on the oldest glibc and wrapped as .deb, Flatpak or AppImage on Linux. Flutter ships no updater, so that is yours.
Windows: flutter build windows --release leaves the .exe beside flutter_windows.dll, plugin DLLs and data/; ship the whole folder, because the exe alone starts and dies. Package it as MSIX (dart run msix:create) or with an installer, and sign it; an unsigned or new certificate still meets SmartScreen warnings until it builds reputation
macOS: sign with a Developer ID Application identity and the hardened runtime, submit with xcrun notarytool submit --wait, then xcrun stapler staple; Gatekeeper blocks an unnotarized app. Re-signing needs --deep so nested frameworks are covered, and the entitlements file must be passed explicitly with --entitlements or codesign strips it
macOS sandbox: outgoing network needs com.apple.security.network.client, and the template lacks it in both DebugProfile.entitlements and Release.entitlements
Linux: the bundle is tied to the glibc and GTK of the build machine, so build in a container matching the oldest distro you support, then wrap it as .deb, Flatpak, Snap or AppImage
Updating: auto_updater wraps Sparkle and WinSparkle over an appcast you host; MSIX can update through App Installer; on Linux it is the package manager or Flatpak. Whatever you pick, the client must verify a signature on the update