DEV Community

kanta13jp1
kanta13jp1

Posted on

Flutter Web Performance Optimization — CanvasKit, Lazy Loading, and Image Best Practices

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
Enter fullscreen mode Exit fullscreen mode

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());
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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'),
)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  );
}
Enter fullscreen mode Exit fullscreen mode

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),
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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' })
);
Enter fullscreen mode Exit fullscreen mode

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 (0)