Lesson 72 of 77 advanced

Memory Profiling, Leak Detection & DevTools Mastery

Find and fix memory leaks, decode the GC, and use DevTools like a performance surgeon

Open interactive version (quiz + challenge)

Real-world analogy

Your app's memory is like a restaurant kitchen. Objects are ingredients — you bring them in (allocate), cook with them (use), and clean up (GC collects). A memory leak is like a cook who keeps bringing in fresh ingredients but never throws away the scraps — eventually the kitchen overflows and the whole restaurant shuts down (OOM crash). DevTools is your health inspector: it shows you which ingredients are piling up, which cook is hoarding them, and exactly when the kitchen gets too full.

What is it?

Memory profiling is the practice of monitoring your Flutter app's memory allocation, retention, and garbage collection to identify leaks and optimize resource usage. DevTools provides heap snapshots, allocation tracking, timeline analysis, and widget rebuild profiling. Mastering these tools separates developers who guess at performance from those who measure and fix with precision.

Real-world relevance

In a production app serving thousands of users on low-end Android devices (1-2GB RAM), memory management is critical. A forgotten StreamSubscription in a chat screen causes memory to grow with every navigation cycle. A full-resolution image gallery consumes 500MB+ without cacheWidth/cacheHeight. DevTools Memory tab reveals the leak in minutes — the retainer path shows the exact subscription holding the State object alive. Fix, measure again, confirm the sawtooth GC pattern replaces the upward trend.

Key points

Code example

// Memory profiling patterns and leak fixes

import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';

// ===== LEAK: Uncancelled Stream Subscription =====

// BAD — memory leak
class LeakyChat extends StatefulWidget {
  const LeakyChat({super.key});
  @override
  State<LeakyChat> createState() => _LeakyChatState();
}

class _LeakyChatState extends State<LeakyChat> {
  late final StreamSubscription _subscription;
  final List<String> _messages = [];

  @override
  void initState() {
    super.initState();
    // This subscription is NEVER cancelled!
    _subscription = chatStream.listen((msg) {
      setState(() => _messages.add(msg));
      // Closure captures 'this' — State can't be GC'd
    });
  }

  // Missing dispose()! The subscription keeps this State alive forever.

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: _messages.length,
      itemBuilder: (_, i) => Text(_messages[i]),
    );
  }
}

// FIXED — properly disposed
class FixedChat extends StatefulWidget {
  const FixedChat({super.key});
  @override
  State<FixedChat> createState() => _FixedChatState();
}

class _FixedChatState extends State<FixedChat> {
  late final StreamSubscription _subscription;
  final List<String> _messages = [];

  @override
  void initState() {
    super.initState();
    _subscription = chatStream.listen((msg) {
      if (mounted) {
        setState(() => _messages.add(msg));
      }
    });
  }

  @override
  void dispose() {
    _subscription.cancel(); // CRITICAL — break the reference chain
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: _messages.length,
      itemBuilder: (_, i) => Text(_messages[i]),
    );
  }
}

// ===== IMAGE MEMORY OPTIMIZATION =====

class OptimizedImage extends StatelessWidget {
  final String url;
  const OptimizedImage({super.key, required this.url});

  @override
  Widget build(BuildContext context) {
    return Image.network(
      url,
      // Decode at display size, not full resolution
      // 4000x3000 image → 200x150 in memory = 120KB instead of 48MB
      cacheWidth: 200,
      cacheHeight: 150,
      fit: BoxFit.cover,
      errorBuilder: (_, error, __) => const Icon(Icons.error),
    );
  }
}

// Memory pressure handler
class MemoryAwareApp extends StatefulWidget {
  const MemoryAwareApp({super.key});
  @override
  State<MemoryAwareApp> createState() => _MemoryAwareAppState();
}

class _MemoryAwareAppState extends State<MemoryAwareApp>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didHaveMemoryPressure() {
    // OS is running low on memory — free caches
    imageCache.clear();
    imageCache.clearLiveImages();
    debugPrint('Memory pressure: cleared image cache');
    // Log to analytics for monitoring
  }

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: HomeScreen());
  }
}

// ===== CUSTOM TIMELINE EVENTS FOR PROFILING =====

Future<List<Product>> fetchProducts() async {
  // Custom timeline event — visible in DevTools Performance tab
  developer.Timeline.startSync('fetchProducts');
  try {
    final response = await apiClient.get('/products');
    developer.Timeline.startSync('parseProducts');
    final products = (response.data as List)
        .map((json) => Product.fromJson(json))
        .toList();
    developer.Timeline.finishSync(); // end parseProducts
    return products;
  } finally {
    developer.Timeline.finishSync(); // end fetchProducts
  }
}

// ===== WIDGET REBUILD TRACKER =====

class RebuildTracker extends StatelessWidget {
  final String name;
  final Widget child;

  const RebuildTracker({
    super.key,
    required this.name,
    required this.child,
  });

  @override
  Widget build(BuildContext context) {
    // Only in debug mode — zero cost in release
    if (kDebugMode) {
      debugPrint('REBUILD: $name at ${DateTime.now().millisecondsSinceEpoch}');
    }
    return child;
  }
}

// ===== LEAK DETECTION HELPER FOR TESTS =====

/// Tracks object disposal in debug mode
mixin DisposableTracker<T extends StatefulWidget> on State<T> {
  final _activeSubscriptions = <String, StreamSubscription>{};
  final _activeControllers = <String, AnimationController>{};

  void trackSubscription(String id, StreamSubscription sub) {
    _activeSubscriptions[id] = sub;
  }

  void trackController(String id, AnimationController ctrl) {
    _activeControllers[id] = ctrl;
  }

  @override
  void dispose() {
    // Auto-cancel all tracked subscriptions
    for (final entry in _activeSubscriptions.entries) {
      entry.value.cancel();
      if (kDebugMode) {
        debugPrint('Auto-cancelled subscription: ${entry.key}');
      }
    }
    // Auto-dispose all tracked controllers
    for (final entry in _activeControllers.entries) {
      entry.value.dispose();
      if (kDebugMode) {
        debugPrint('Auto-disposed controller: ${entry.key}');
      }
    }
    _activeSubscriptions.clear();
    _activeControllers.clear();
    super.dispose();
  }
}

// Usage:
// class _MyState extends State<MyWidget> with DisposableTracker<MyWidget> {
//   void initState() {
//     super.initState();
//     trackSubscription('chat', chatStream.listen(onMessage));
//     trackController('fade', AnimationController(vsync: this));
//   }
//   // No need to manually dispose — DisposableTracker handles it
// }

// ===== MEMORY USAGE SUMMARY =====

class MemoryInfo {
  /// Get current Dart heap usage (debug only)
  static Map<String, dynamic> getHeapUsage() {
    // Uses dart:developer ProcessInfo in debug mode
    return {
      'currentRss': ProcessInfo.currentRss,      // Resident Set Size
      'maxRss': ProcessInfo.maxRss,                // Peak RSS
      'timestamp': DateTime.now().toIso8601String(),
    };
  }

  /// Log memory snapshot for comparison
  static void logSnapshot(String label) {
    if (kDebugMode) {
      final info = getHeapUsage();
      debugPrint('MEMORY [$label]: RSS=${(info["currentRss"] / 1024 / 1024).toStringAsFixed(1)}MB, '
          'Peak=${(info["maxRss"] / 1024 / 1024).toStringAsFixed(1)}MB');
    }
  }
}

Line-by-line walkthrough

  1. 1. LeakyChat: subscription captures 'this' via setState closure — without cancel() in dispose(), this State + its widget subtree can never be GC'd
  2. 2. FixedChat: dispose() cancels the subscription + mounted check prevents setState after dispose — the two-part fix for subscription leaks
  3. 3. OptimizedImage: cacheWidth/cacheHeight decode at display resolution — a 4000x3000 image uses 120KB instead of 48MB in memory
  4. 4. MemoryAwareApp: WidgetsBindingObserver.didHaveMemoryPressure fires when the OS is low on memory — clear imageCache to free decoded images
  5. 5. developer.Timeline.startSync/finishSync: custom labeled events appear in DevTools Performance timeline — nested events show sub-operation timing
  6. 6. RebuildTracker: wraps any widget to log rebuilds in debug mode — kDebugMode check means zero cost in release builds
  7. 7. DisposableTracker mixin: auto-cancels all tracked subscriptions and controllers in dispose() — a safety net against forgotten cleanup
  8. 8. trackSubscription/trackController: register resources by name — the mixin cancels/disposes them all, with debug logging to verify cleanup
  9. 9. MemoryInfo.getHeapUsage: ProcessInfo.currentRss shows resident set size — compare snapshots before/after user flows to detect growth
  10. 10. logSnapshot: formatted debug output shows RSS in MB at labeled points — 'before scroll' vs 'after scroll' reveals memory behavior

Spot the bug

class SearchScreen extends StatefulWidget {
  const SearchScreen({super.key});
  @override
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  Timer? _debounce;
  List<String> _results = [];

  void _onSearch(String query) {
    _debounce?.cancel();
    _debounce = Timer(const Duration(milliseconds: 300), () async {
      final results = await searchApi(query);
      setState(() => _results = results);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(onChanged: _onSearch),
        Expanded(
          child: ListView.builder(
            itemCount: _results.length,
            itemBuilder: (_, i) => ListTile(title: Text(_results[i])),
          ),
        ),
      ],
    );
  }
}
Need a hint?
Two memory-related issues hide in this code. One causes a leak, the other causes a framework error. Think about what happens when the user presses the back button while a search is pending.
Show answer
Two bugs: (1) Memory leak: _debounce Timer is never cancelled in dispose(). If the user navigates away while the 300ms debounce is pending, the Timer fires, the closure captures 'this' (the State), and calls setState on a disposed State. Fix: add `@override void dispose() { _debounce?.cancel(); super.dispose(); }`. (2) setState after dispose: even with the dispose fix, there's a race condition — the API call (searchApi) is async. If the user navigates away after the Timer fires but before the API returns, setState is called on a disposed widget. Fix: add a `mounted` check: `if (mounted) setState(() => _results = results);`. Both issues together cause the State to be retained in memory AND throw framework errors.

Explain like I'm 5

Imagine your room is your app's memory. Every time you play with a toy (use an object), you should put it back on the shelf when you're done (dispose). If you keep taking out toys but never putting them back, your room gets so messy you can't move (the app crashes). DevTools is like a magic camera that takes a picture of your room and shows you exactly which toys are on the floor and who forgot to put them away.

Fun fact

Instagram's Android team discovered that a single undisposed ExoPlayer instance was consuming 50MB of memory per video view — with users scrolling through feeds of hundreds of videos, this caused OOM crashes on 30% of low-end devices. The fix (properly releasing players when off-screen) reduced OOM crash rate by 85%. Memory leaks aren't just technical debt — they're user-facing crashes that show up in app store ratings.

Hands-on challenge

Perform a memory audit on an existing Flutter project: (1) Open DevTools Memory tab and take a baseline heap snapshot. (2) Navigate to a screen with a list, scroll through 50 items, then navigate back. Repeat 5 times. (3) Take another heap snapshot and diff with the baseline. (4) Identify any classes whose instance count grows with each navigation cycle — these are leak candidates. (5) Check the retainer path for each suspect and fix the leak (cancel subscriptions, dispose controllers). (6) Repeat the test and verify the diff shows zero growth.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery