You are working on a Flutter web build. Everything here is a lesson that cost real hours — each one looks like an application bug and is not.
Browser QA only counts in a visible tab
Flutter web drives its entire frame pipeline from requestAnimationFrame,
and Chrome throttles rAF to near zero in a tab that is not the active one. In a
background tab the app still runs — events are processed, network calls fire —
but it barely paints. So route transitions appear frozen for tens of seconds,
and click coordinates read off a screenshot land nowhere, because the painted
frame no longer matches the real hit-test layout.
That looks exactly like two bugs that do not exist: a slow splash screen and broken Tab traversal.
Before blaming the app:
document.visibilityState // must be "visible"
If it reports hidden, bring the browser window forward and make the tab
active. Two things stay reliable either way, and are the better evidence:
performance.getEntriesByType('resource')for real timings — in one case it showed auth resolving at 1.4s while the screen looked stuck for 20s.document.activeElement, and the hidden<input>Flutter creates for the focused text field, which proves where focus actually is.
Each CDP screenshot forces one frame, so consecutive screenshots step a stalled transition forward — useful, and itself a hint that you are in a throttled tab.
Browser-layer code needs the Chrome test runner
Anything touching dart:js_interop or package:web — getUserMedia,
MediaRecorder, an <audio> element — cannot even compile under the
default VM test runner.
@TestOn('browser') is what makes that survivable: flutter test skips an
annotated file instead of compiling it. The browser layer is untested by
default, not untestable:
flutter test # VM: controllers, repositories, pure widgets
flutter test --platform chrome # the browser layer
Both commands run over test/. Do not move browser tests to a directory of
their own — the web runner builds its entrypoint with test/ as the root, so a
path outside it becomes a ../ that does not resolve and nothing compiles at
all. The annotation is the separator, not the directory.
The trap that makes this dangerous: flutter test alone reports green over
a browser test that never compiled. Run both before pushing, and run both in
CI as two separate steps.
--platform chrome is also flaky or unavailable on some machines (a file with
@TestOn('browser') hanging at loading until the test timeout is the usual
symptom). Where that happens, CI is the only place the browser layer runs — so
prefer pulling the logic out of the browser-bound file into a plain Dart
one and testing that on the VM runner.
Make it testable: contract first, js_interop behind it
Declare the minimum contract as an abstract class, keep the js_interop
implementation behind it, and inject it so a widget test can pass a fake:
abstract class AudioRecorder {
Future<void> start();
Future<Uint8List?> stop();
void dispose();
}
// browser_audio_recorder.dart — the only file importing package:web
class BrowserAudioRecorder implements AudioRecorder { ... }
Every exit path must release a browser resource
When a widget holds a browser resource, release it on every way out — not only the happy one:
dispose()unconditionally, rather than only when a_recordingflag flipped.- A release when the screen turns out to be unmounted after an
await. - A guard so a second tap during the first
awaitis a no-op.
A real leak was exactly the gap between those: permission was still pending, so
the flag had not flipped, so dispose() released nothing — and an orphaned
ticker wrote to a closed StreamController every 200 ms for the life of the tab.
Blank white page after changing a path dependency
Path dependencies (a design_system/ or calendar/ package in the same repo)
are not always picked up by the incremental web build. It emits a bundle that
compiles, loads every asset, throws no JS error — and renders a blank white page
with an empty <flutter-view>. Hours disappear looking for a bug in code that
is fine.
rm -rf build/web && flutter build web --release --dart-define=BASE_URL=...
Do that before concluding anything from a blank screen.
Watch the import chain, not just the import
A web-only import poisons every file that reaches it transitively, and the compiler names the file that failed — not the file at fault.
The classic shape: a app_routes.dart holding both the route-name constants
and the route table. The table imports every screen, one of which imports
dart:ui_web. A sign-out use case importing that file for one string
constant then makes every VM test that touches it fail to compile, taking the
whole suite with it.
The fix is to split the constants into a file that imports nothing:
// app_route_names.dart — imports nothing
class AppRouteNames {
static const login = '/login';
static const home = '/home';
}
// app_routes.dart — re-exports the names, and owns the table
export 'app_route_names.dart';
class AppRoutes { /* onGenerateRoute + the table */ }
Re-export at the library level, with export. Do not try to inherit the
names (class AppRoutes extends AppRouteNames): Dart does not inherit static
members, so AppRoutes.login would not resolve even when it compiles — and if
the names class is final, extending it is a compile error outright. Callers
say AppRouteNames.login; the export is what saves them a second import.
Screens and the root widget may import the table. A use case, a repository or an interceptor may not — they take the names file.
So when the VM suite fails to load a file rather than fail an assertion, read which file it named, then walk the import chain back to the web-only leaf.
Checklist before shipping a web change
flutter analyze
flutter test # VM
flutter test --platform chrome # browser layer
rm -rf build/web
flutter build web --release --dart-define=BASE_URL=<api-url>
Common mistakes
- Debugging a "frozen" screen in a background tab. Check
document.visibilityStatefirst. - Trusting a green
flutter testover code that only compiles on the browser runner. - Moving browser tests out of
test/—--platform chromecannot resolve a path outside it. - Calling
js_interopstraight from a widget instead of behind an injectable interface. - Releasing a browser resource only on the happy path. Unmount during an
awaitis a real exit. - Concluding "blank page = my bug" without a clean
build/webrebuild first. - Importing the route table from a use case or interceptor just to read one route name.
- Forgetting
--dart-define=BASE_URL, so the compile-time constant is""and every call fails. See flutter-run. - Leaving semantics off — Flutter web disables them by default. See flutter-accessibility.