Flutter Dynamic Feature Delivery: A Practical, End‑to‑End Guide

A practical guide to Flutter dynamic feature delivery: design, deferred imports, Android deferred components, UX patterns, testing, and pitfalls.

ASOasis
8 min read
Flutter Dynamic Feature Delivery: A Practical, End‑to‑End Guide

Image used for representation purposes only.

Overview

Dynamic feature delivery lets you ship a small, fast‑installing base app and download heavyweight features only when the user actually needs them. In Flutter, this typically means combining Dart’s deferred imports (lazy loading) with Android App Bundle dynamic feature modules (often called “deferred components” in Flutter tooling). The result: shorter initial downloads, faster time‑to‑first‑open, and a cleaner architecture that keeps optional features out of your critical path.

This guide explains how dynamic feature delivery works in Flutter, when to use it, and how to implement, test, and operate it in production.

Why dynamic delivery matters

  • Reduce initial install size by moving rarely used code and assets out of the base.
  • Improve conversion: smaller downloads mean fewer users drop off before launch.
  • Deliver premium or regional features on demand to the cohorts that need them.
  • Enable staged rollouts of large assets (ML models, map tiles, media packs) without bloating the base.

What Flutter supports today

  • Android: Full support via Play Feature Delivery + Flutter’s deferred components. You can split Dart AOT code and Flutter assets into on‑demand modules.
  • iOS: Apple does not allow downloading new executable code at runtime. You can still apply lazy loading patterns inside your Dart code and use On‑Demand Resources for assets, but dynamic code delivery is Android‑only. Plan for functional parity without assuming code downloads on iOS.

Core building blocks

  1. Dart deferred imports

    • Lets you postpone loading a library until it is first needed.
    • Calling library.loadLibrary() triggers the load; after that, you can reference its symbols normally.
  2. Android App Bundle dynamic features (deferred components)

    • Your bundle is split into a base module plus optional feature modules.
    • Delivery types: install‑time (ships with base) or on‑demand (downloaded later). Conditional delivery is also supported (e.g., device features, countries, min SDK). For dynamic delivery, choose on‑demand.

Designing your split strategy

  • Keep the base minimal: app shell, login, navigation, error screens, analytics, and tiny feature set to demonstrate value.
  • Group by user journeys, not files: e.g., “Editor”, “AR Scanner”, “Premium Insights.” Coarse‑grained modules reduce dependency tangles and cut the number of network fetches.
  • Avoid cross‑module cycles: extract small interfaces (abstract classes) into the base so features can implement them behind the boundary.
  • Consider assets and ML models: co‑locate large assets with the code that needs them so a single download unblocks the whole experience.

Step 1: Mark libraries as deferred

Use Dart’s deferred as import to lazy‑load non‑critical code.

// lib/routes.dart
import 'package:flutter/material.dart';
import 'package:my_app/features/premium/premium_screen.dart' deferred as premium;

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  Future<void> _openPremium(BuildContext context) async {
    // Triggers the first‑time download on Android (for on‑demand modules)
    await premium.loadLibrary();
    Navigator.of(context).push(
      MaterialPageRoute(builder: (_) => premium.PremiumScreen()),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => _openPremium(context),
          child: const Text('Open Premium'),
        ),
      ),
    );
  }
}

Tips:

  • Put only what you truly need behind the boundary. Keep shared types (interfaces, DTOs) in the base.
  • Avoid deferred‑importing files that register platform plugins; keep plugin wiring in the base to reduce complexity.

Step 2: Declare deferred components and assets

Flutter exposes a configuration for mapping deferred Dart libraries (and assets/fonts) to Android dynamic feature modules. A typical, high‑level structure looks like this (schema simplified for clarity; consult the Flutter tool’s current docs when wiring your project):

# pubspec.yaml (illustrative)
flutter:
  assets:
    - assets/shared/icons/

  # Associate libraries and assets with on‑demand components (Android)
  deferred-components:
    - name: premium_feature
      libraries:
        - package:my_app/features/premium/premium_screen.dart
      assets:
        - assets/premium/
      fonts:
        - family: Inter
          fonts:
            - asset: assets/fonts/Inter-Regular.ttf
    - name: ar_scanner
      libraries:
        - package:my_app/features/ar/scanner.dart
      assets:
        - assets/ar/models/

What this achieves:

  • The Dart AOT units for each listed library are built into separate loading units.
  • The specified assets and fonts move into the same Android dynamic feature module, ensuring a single on‑demand download unblocks the UI and its resources.

Step 3: Build the Android App Bundle

Generate your release bundle with deferred components enabled and testable splits.

# Build the release bundle
flutter build appbundle --release

# Optional: build architecture‑specific if you want smaller APKs via bundletool
flutter build appbundle --release --target-platform android-arm64

To exercise dynamic delivery without the Play Console, use bundletool to create and install config‑matched APKs on a connected device:

# Build a set of APKs from the AAB
java -jar bundletool.jar build-apks \
  --bundle=build/app/outputs/bundle/release/app-release.aab \
  --output=dist/app.apks \
  --connected-device

# Install those APKs on the device
java -jar bundletool.jar install-apks --apks=dist/app.apks

With this setup, calling premium.loadLibrary() will request the on‑demand module. On first load, show a lightweight progress UI; subsequent loads are instant because the module is cached locally (unless the user or Play uninstalls it).

Step 4: Provide great UX during downloads

A simple helper widget keeps your UI responsive while a module downloads and the library loads.

class DeferredFeature extends StatefulWidget {
  const DeferredFeature({
    super.key,
    required this.loader, // e.g., premium.loadLibrary
    required this.builder, // builds the loaded screen
    this.placeholder,
    this.onError,
  });

  final Future<void> Function() loader;
  final WidgetBuilder builder;
  final Widget? placeholder;
  final void Function(Object error)? onError;

  @override
  State<DeferredFeature> createState() => _DeferredFeatureState();
}

class _DeferredFeatureState extends State<DeferredFeature> {
  Object? _error;
  bool _loaded = false;

  @override
  void initState() {
    super.initState();
    widget.loader()
      .then((_) => setState(() => _loaded = true))
      .catchError((e) {
        setState(() => _error = e);
        widget.onError?.call(e);
      });
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) {
      return Center(child: Text('Could not load feature.'));
    }
    if (!_loaded) {
      return widget.placeholder ?? const Center(child: CircularProgressIndicator());
    }
    return widget.builder(context);
  }
}

Usage:

DeferredFeature(
  loader: premium.loadLibrary,
  builder: (_) => premium.PremiumScreen(),
  placeholder: const PremiumSkeleton(),
)

UX best practices:

  • Show an inline skeleton or a lightweight placeholder rather than a blocking dialog.
  • If the download is large, offer an explicit user choice (e.g., “Download 25 MB over Wi‑Fi only”).
  • Consider preloading after intent signals (e.g., user taps “Premium” tab) while they read an explainer.

Step 5: Preload strategically

You can proactively fetch an on‑demand module when you predict usage, then present the screen instantly later.

Patterns:

  • Time‑based: warm up after app idle, only on unmetered network and sufficient battery.
  • Behavior‑based: user navigates near the boundary (e.g., lands on a teaser card).
  • Cohort‑based: known premium users; devices meeting ARCore/ML requirements.

Remember: downloads are version‑pinned to the installed app. When users update the app, the modules update too.

Testing and CI considerations

  • Unit/widget tests run against source; they don’t download modules. Design boundaries that are easy to mock and test in process.
  • Use an internal testing track in Play to validate delivery types, conditional rules, and real‑world networks.
  • Automate size budgets in CI: track base size, each module’s size, and regression thresholds.
  • Verify that cold‑start performance improves; don’t just chase a smaller APK—measure time‑to‑first‑frame and first interaction.

Analytics and observability

Instrument the dynamic delivery journey:

  • Time to download and load per module (p50/p95).
  • Failures and causes (network, storage, Play service errors).
  • User cancellations.
  • Cache hit rate (second‑time usage should be instant).
  • Conversion impact: Did smaller base size improve first‑launch rates?

iOS alternatives and parity strategy

  • No dynamic code downloads. Keep the same deferred‑import boundaries for architectural clarity, but they’ll simply load from the app bundle.
  • Use On‑Demand Resources for large asset packs if appropriate, or remote content streaming (CDN) for media and map data.
  • Maintain feature parity by gating with feature flags and offering progressive disclosure rather than relying on downloads.

Common pitfalls and how to avoid them

  • Over‑fragmentation: Too many tiny modules create latency from multiple round‑trips. Aim for a handful of well‑scoped features.
  • Cross‑module imports: If two modules depend on each other, extract shared APIs into the base.
  • Plugin registration inside deferred libraries: Keep plugin wiring in the base. Call into plugins through interfaces.
  • Large first‑use jank: Avoid heavy synchronous work in initState of the first loaded screen; defer additional IO to post‑frame callbacks or isolates.
  • Ignoring offline flows: Provide graceful degradation and messaging when the device is offline.
  • Asset mismatches: Ensure assets referenced by a deferred screen are declared inside the same component so the first download includes them.

Security and compliance notes

  • Dynamic feature delivery does not let you execute arbitrary fresh code; all code must be shipped in the signed App Bundle you upload to the store.
  • Respect user data limits. Clearly communicate sizes and allow Wi‑Fi‑only downloads for big modules.

Performance checklist

  • Base module under a target threshold (e.g., <20–25 MB installed).
  • First contentful paint and first interaction times measured before/after split.
  • Each on‑demand module sized and compressed (tree‑shaken Dart, shrinker/R8 for Android resources, image compression/WebP/AVIF where applicable).
  • Background preloading guarded by connectivity and battery heuristics.
  • Telemetry in place for download times and failure rates.

Example module map (conceptual)

  • Base: Auth, Home, Settings, Analytics, Shared UI kit
  • premium_feature (on‑demand): Paywall, Premium content views, premium assets
  • ar_scanner (on‑demand + conditional min SDK / device feature): AR camera, models, shaders
  • insights (on‑demand): Charts, ML inference models, report exporters

Deployment tips

  • Start with one pilot module to validate toolchain and analytics.
  • Use internal and closed testing tracks to verify delivery rules before public rollout.
  • Communicate changes in release notes: “Now a smaller install; features download on demand.”
  • Watch crash/ANR dashboards around loadLibrary() entry points.

Summary

Dynamic feature delivery in Flutter pairs Dart’s deferred imports with Android App Bundle feature modules to deliver a smaller base app and faster first‑run, while letting you fetch heavyweight features only when needed. Design coarse‑grained boundaries, map code and assets together, provide a smooth loading UX, and instrument the funnel. On iOS, retain the same architectural seams but rely on bundled code and optional on‑demand assets. With thoughtful splits and robust telemetry, you’ll ship leaner apps that feel faster—and only grow when users ask them to.

Related Posts