Shipping a production-ready Flutter Web app requires going beyond default settings. This guide covers renderer selection, deferred imports, image optimization, profiling, and build-time optimizations — all with practical code examples.
1. CanvasKit vs HTML Renderer
Flutter Web ships two renderers. Picking the right one is the highest-leverage decision you can make.
| Aspect | CanvasKit | HTML |
|---|---|---|
| Rendering quality | High (Skia) | Medium (CSS/SVG) |
| Initial bundle size | +~1.5 MB | Smaller |
| Animation smoothness | Excellent | Limited |
| Text rendering | Consistent | OS font dependent |
| SEO friendliness | Hard | Easier |
# Development — specify renderer
flutter run -d chrome --web-renderer canvaskit
flutter run -d chrome --web-renderer html
# Production builds
flutter build web --web-renderer canvaskit --release
flutter build web --web-renderer html --release
# auto: CanvasKit on desktop, HTML on mobile
flutter build web --web-renderer auto --release
# WASM compilation (Flutter 3.24+, experimental)
flutter build web --wasm --release
Rule of thumb: graphics-heavy apps → CanvasKit; content-heavy apps → HTML renderer. WASM compilation can further accelerate CanvasKit for CPU-bound workloads by 2–3×.
2. Deferred Imports for Code Splitting
Dart's deferred as keyword enables route-level code splitting, keeping the initial JS bundle minimal.
// main.dart — defer heavy pages
import 'pages/dashboard_page.dart' deferred as dashboard;
import 'pages/analytics_page.dart' deferred as analytics;
import 'pages/settings_page.dart' deferred as settings;
class AppRouter {
static Route<dynamic> generateRoute(RouteSettings routeSettings) {
switch (routeSettings.name) {
case '/dashboard':
return MaterialPageRoute(
builder: (_) => FutureBuilder<void>(
future: dashboard.loadLibrary(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return dashboard.DashboardPage();
}
return const LoadingScreen();
},
),
);
case '/analytics':
return MaterialPageRoute(
builder: (_) => FutureBuilder<void>(
future: analytics.loadLibrary(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
return analytics.AnalyticsPage();
},
),
);
default:
return MaterialPageRoute(builder: (_) => const HomePage());
}
}
}
Preload on hover — load the next page before the user clicks:
class NavItem extends StatelessWidget {
final String label;
final Future<void> Function() preload;
final VoidCallback onTap;
const NavItem({
super.key,
required this.label,
required this.preload,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => preload(), // background load on hover
child: GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(label),
),
),
);
}
}
// Usage
NavItem(
label: 'Dashboard',
preload: dashboard.loadLibrary,
onTap: () => context.go('/dashboard'),
)
3. Lazy Rendering with IntersectionObserver
Use the browser's IntersectionObserver API to avoid building widgets that are off-screen.
import 'dart:html' as html;
import 'package:flutter/widgets.dart';
/// Renders [child] only once it enters the viewport.
/// Shows [placeholder] until then.
class LazyWidget extends StatefulWidget {
final Widget child;
final Widget placeholder;
/// Preload distance in pixels above the fold
final int rootMarginPx;
const LazyWidget({
super.key,
required this.child,
this.placeholder = const SizedBox.shrink(),
this.rootMarginPx = 200,
});
@override
State<LazyWidget> createState() => _LazyWidgetState();
}
class _LazyWidgetState extends State<LazyWidget> {
bool _visible = false;
html.IntersectionObserver? _observer;
@override
void initState() {
super.initState();
_observer = html.IntersectionObserver(
(entries, _) {
if (entries.any((e) => e.isIntersecting)) {
setState(() => _visible = true);
_observer?.disconnect();
}
},
{'rootMargin': '${widget.rootMarginPx}px'},
);
// Attach after the first frame so the DOM element exists
WidgetsBinding.instance.addPostFrameCallback((_) => _attach());
}
void _attach() {
// Real implementation uses PlatformViewRegistry to get the DOM node
// This pattern works with HtmlElementView or flutter_web_plugins
}
@override
void dispose() {
_observer?.disconnect();
super.dispose();
}
@override
Widget build(BuildContext context) =>
_visible ? widget.child : widget.placeholder;
}
4. Image Optimization
ResizeImage — Decode at Display Resolution
/// Constrain decode resolution to the actual display size,
/// saving significant heap memory on mobile and high-DPI screens.
Widget optimizedNetworkImage(
BuildContext context,
String url, {
double? displayWidth,
double? displayHeight,
}) {
final dpr = MediaQuery.devicePixelRatioOf(context);
return Image(
image: ResizeImage(
NetworkImage(url),
width: displayWidth != null ? (displayWidth * dpr).round() : null,
height: displayHeight != null ? (displayHeight * dpr).round() : null,
),
fit: BoxFit.cover,
);
}
CachedNetworkImage with Memory + Disk Limits
import 'package:cached_network_image/cached_network_image.dart';
Widget cachedImage(String url) {
return CachedNetworkImage(
imageUrl: url,
memCacheWidth: 800, // px — limits decoded size in memory
memCacheHeight: 600,
maxWidthDiskCache: 1200, // px — limits size written to disk
fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, __) => const ShimmerBox(),
errorWidget: (_, __, ___) => const Icon(Icons.broken_image),
);
}
Supabase Storage Image Transforms
/// Use Supabase's built-in image transformation API.
/// Automatically serves WebP when the browser supports it.
String supabaseImageUrl(
String bucket,
String path, {
int width = 800,
int quality = 80,
}) {
const projectRef = 'YOUR_PROJECT_REF';
return 'https://$projectRef.supabase.co'
'/storage/v1/render/image/public/$bucket/$path'
'?width=$width&quality=$quality&format=webp';
}
// Usage — responsive image for a card
Widget productCard(String imagePath) {
return LayoutBuilder(
builder: (context, constraints) {
final url = supabaseImageUrl(
'products',
imagePath,
width: constraints.maxWidth.round(),
);
return CachedNetworkImage(imageUrl: url, fit: BoxFit.cover);
},
);
}
5. Profiling with Flutter DevTools
# Run in profile mode — release optimizations, DevTools still works
flutter run -d chrome --profile
# Measure output bundle size
flutter build web --analyze-size --release
Finding jank in the Timeline:
// Instrument slow sections manually
import 'dart:developer' as developer;
Future<void> loadDashboardData() async {
developer.Timeline.startSync('LoadDashboardData');
try {
final results = await Future.wait([
_fetchMetrics(),
_fetchNotifications(),
_fetchRecentActivity(),
]);
// process results...
} finally {
developer.Timeline.finishSync();
}
}
Frame timing callback (debug builds only):
void main() {
WidgetsFlutterBinding.ensureInitialized();
assert(() {
WidgetsBinding.instance.addTimingsCallback((timings) {
for (final t in timings) {
final ms = t.totalSpan.inMilliseconds;
if (ms > 16) debugPrint('Slow frame: ${ms}ms (build: ${t.buildDuration.inMilliseconds}ms)');
}
});
return true;
}());
runApp(const App());
}
6. Build-time Optimizations
split-debug-info + obfuscate
flutter build web \
--release \
--web-renderer canvaskit \
--split-debug-info=build/symbols \
--obfuscate \
--dart-define=FLUTTER_WEB_USE_SKIA=true
# Check output size
ls -lh build/web/main.dart.js
# Typical: 2–3 MB (CanvasKit), 1–1.5 MB (HTML renderer)
Avoid Breaking Tree-Shaking
// Bad — dynamic icon lookup defeats tree-shaking
IconData iconFromCode(int codePoint) =>
IconData(codePoint, fontFamily: 'MaterialIcons');
// Good — static map keeps icons tree-shakeable
const _iconMap = {
'home': Icons.home,
'person': Icons.person,
'settings': Icons.settings,
};
IconData? iconFor(String key) => _iconMap[key];
Service Worker Cache Strategy
// Customize web/flutter_service_worker.js or use Workbox
// Cache-first for images (long TTL)
workbox.routing.registerRoute(
({ request }) => request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'images-v1',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 150,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// Network-first for API calls (freshness matters)
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new workbox.strategies.NetworkFirst({ cacheName: 'api-cache' })
);
Summary
| Optimization | Impact | Effort |
|---|---|---|
| Renderer selection | 30–50% faster initial load | Low |
| Deferred imports | Smaller initial JS bundle | Medium |
| ResizeImage | 40–60% less heap memory | Low |
| split-debug-info | ~20% smaller binary | Low |
| WASM build | 2–3× faster CPU-bound code | Medium |
Always measure before optimizing. Use DevTools to find real bottlenecks — then apply targeted fixes. Next week we look at Supabase Realtime for live collaborative features.
Top comments (1)
Really solid breakdown. I’ve seen the biggest win just from switching to deferred imports — initial load dropped a lot without touching UI. Also +1 on ResizeImage, people seriously underestimate how much memory images eat on web.