Skip to content

[Impeller] Repeated RepaintBoundary.toImage() leaks native memory (snapshot HostBuffer never recycled) #192097

Description

@alesms

Steps to reproduce

  1. Run the code sample below on an iOS simulator (observed on iPhone 17 Pro, iOS 26.0) in debug or profile mode.
  2. Let it sit on the static screen while it calls RenderRepaintBoundary.toImage() periodically (the sample uses a 100 ms timer; the production app where we found this samples every 300 ms). The returned ui.Image is disposed after every call.
  3. Watch the process footprint, e.g. footprint <pid> or Xcode's memory gauge.

Expected results

Memory stays flat: every ui.Image is disposed, the screen content never changes, and DevTools confirms the Dart heap is flat.

Actual results

Native memory grows without bound and is never released. Each toImage() call permanently retains ~11 KB in our measurements (24 px-wide thumbnail snapshot of a mostly static phone screen; the per-call cost likely scales with snapshot content). The growth is invisible to DevTools because it is entirely on the native side.

Real-world impact that led us here: an app left running on the simulator with a 300 ms sampling timer reached a 27 GB phys_footprint over 9 days (~3 GB/day) and pushed the host Mac deep into swap. A second, unrelated app using the same periodic-toImage pattern showed identical growth (~1.7 GB in 15 h), so this is not app-specific.

footprint attributes all growth to untagged VM_ALLOCATE dirty memory, growing by one ~1 MB region per ~85 toImage() calls — which matches Impeller's HostBuffer allocating a new ~1 MB block each time the previous one fills up, with no block ever recycled.

What the allocations look like

Attaching lldb with a conditional breakpoint on large anonymous allocations, every single hit during a 2-minute window (5/5) had this stack — the only variation was the entity being rendered (TextContents::Render in some hits, SolidRRectLikeBlurContents::Render in others), so it is content-independent:

frame #0: libsystem_kernel.dylib`mmap
frame #1: MTLSimDriver`-[MTLSimDevice newBufferWithLength:options:pointer:copyBytes:deallocator:] + 480
frame #2: Flutter`impeller::AllocatorMTL::OnCreateBuffer(impeller::DeviceBufferDescriptor const&) + 124
frame #3: Flutter`impeller::HostBuffer::MaybeCreateNewBuffer() + 104
frame #4: Flutter`impeller::HostBuffer::EmplaceInternal(void const*, unsigned long, unsigned long) + 88
frame #5: Flutter`impeller::HostBuffer::Emplace(void const*, unsigned long, unsigned long) + 32
frame #6: Flutter`impeller::TextContents::Render(...) / impeller::SolidRRectLikeBlurContents::Render(...)
frame #7: Flutter`impeller::Entity::Render(impeller::ContentContext const&, impeller::RenderPass&) const + 128
frame #8: Flutter`impeller::Canvas::AddRenderEntityToCurrentPass(impeller::Entity&, bool) + 1680
...
frame #16: Flutter`impeller::DisplayListToTexture(sk_sp<flutter::DisplayList> const&, ...) + 1264
frame #17: Flutter`flutter::(anonymous namespace)::DoMakeRasterSnapshot(...) + 368
frame #21: Flutter`flutter::SnapshotControllerImpeller::MakeRasterSnapshot(...) + 264
frame #22: Flutter`flutter::Rasterizer::MakeRasterSnapshot(...) + 136
frame #23: Flutter`flutter::Picture::DoRasterizeToImage(...)

So the offscreen snapshot path (Picture::DoRasterizeToImage → SnapshotControllerImpeller → DisplayListToTexture) keeps appending to a HostBuffer whose backing DeviceBuffers are apparently never reset or returned, unlike the per-frame onscreen HostBuffer which is recycled every frame. This looks like a sibling of #154892 ("Fix memory leak in overlay surface host buffers"), but on the snapshot path instead of the overlay-surface path.

Notes:

Code sample

Code sample
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() => runApp(const LeakApp());

class LeakApp extends StatefulWidget {
  const LeakApp({super.key});

  @override
  State<LeakApp> createState() => _LeakAppState();
}

class _LeakAppState extends State<LeakApp> {
  final GlobalKey _boundaryKey = GlobalKey();
  int _calls = 0;

  @override
  void initState() {
    super.initState();
    Timer.periodic(const Duration(milliseconds: 100), (_) async {
      final object = _boundaryKey.currentContext?.findRenderObject();
      if (object is! RenderRepaintBoundary || object.debugNeedsPaint) return;
      final image = await object.toImage(pixelRatio: 0.1);
      image.dispose();
      _calls++;
      if (_calls % 100 == 0) debugPrint('toImage calls: $_calls');
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: RepaintBoundary(
        key: _boundaryKey,
        child: const Scaffold(
          body: Center(child: Text('toImage leak repro')),
        ),
      ),
    );
  }
}

Logs

flutter doctor (trimmed)
[✓] Flutter (Channel stable, 3.44.9, on macOS 26.5 25F71 darwin-arm64)
    • Framework revision 6b182d2c75 (2026-08-05)
    • Engine hash b9499e4c25212536ba3a4eec4f5c1905fb3214fe
[✓] Xcode - develop for iOS and macOS (Xcode 26.0, build 17A324)
Target: iOS 26.0 simulator, iPhone 17 Pro

Activity

  1. suryaKommana2662 commented on Sep 1, 2026

    @suryaKommana2662
    Contributor

    @alesms Thanks for providing the detailed reproduction and sample. I noticed that you tested this on Flutter 3.44.9. Could you please test the reproduction on the latest Flutter stable release and confirm whether the issue still occurs?

    I tested the provided reproduction on Flutter 3.47.2 using an iPhone 17 Pro simulator with Impeller enabled. The toImage() calls continued to run successfully, and I did not observe any crash or assertion. Memory usage increased initially but appeared to stabilize at around 130 MB after ten thousand calls.

    Flutter Doctor Output

    [✓] Flutter (Channel stable, 3.47.2, on macOS 26.4 25E246 darwin-arm64, locale en-IN) [178ms]
        • Flutter version 3.47.2 on channel stable at /Users/suryakommana/develop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision d3b14c8769 (5 days ago), 2026-08-26 16:07:51 -0700
        • Engine revision a804b26164
        • Dart version 3.13.2
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
          enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [784ms]
        • Android SDK at /Users/suryakommana/Library/Android/sdk
        • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
        • Platform android-37.0, build-tools 36.0.0
        • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
          This is the JDK bundled with the latest Android Studio installation on this machine.
          To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version OpenJDK Runtime Environment (build 25.0.2+-15348964-b329.117)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 26.6) [426ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 17F113
        • CocoaPods version 1.17.0
    
    [✓] Chrome - develop for the web [3ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (3 available) [5.6s]
        • iPhone 17 pro (mobile) • DB7548B3-163D-4C89-A357-B948B8CE1F16 • ios            • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator)
        • macOS (desktop)        • macos                                • darwin-arm64   • macOS 26.4 25E246 darwin-arm64
        • Chrome (web)           • chrome                               • web-javascript • Google Chrome 152.0.7977.65
    
    [✓] Network resources [443ms]
        • All expected network resources are available.
    
    • No issues found!
    suryakommana@SuryaKommanas-MacBook-Pro my_flutter_app % 
  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Sep 1, 2026
  3. alesms commented on Sep 3, 2026

    @alesms
    Author

    @suryaKommana2662 thanks for checking. I re-ran it on 3.47.2 for 20 minutes and the growth is slower but still unbounded.

    Re-tested on Flutter 3.47.2 stable (engine a804b26, Dart 3.13.2), same code sample, iPhone 17 Pro simulator (iOS 26.0), debug mode, 100 ms timer, pixelRatio: 0.1. Memory measured with footprint <pid> (phys_footprint) every 60 s for 20 minutes.

    toImage calls phys_footprint
    100 112 MB
    2 500 129 MB
    4 300 146 MB
    6 100 151 MB
    7 900 156 MB
    9 700 161 MB
    10 900 164 MB
    12 100 167 MB

    It does not stabilize: after the warm-up the growth is linear at ~2.7 KB per call (156 -> 167 MB over the last 4 200 calls), with no sign of a plateau at 12k calls.

    footprint category breakdown, first vs last sample:

    untagged (VM_ALLOCATE)   85 MB / 281 regions  ->  130 MB / 324 regions
    MALLOC_SMALL            5.6 MB               ->   17 MB
    TOTAL                   112 MB               ->  169 MB
    

    So the signature is the same as in the original report (dirty untagged VM_ALLOCATE growing by ~1 MB regions, one new region every ~280 calls now vs every ~85 calls on 3.44.9). The per-call cost is roughly 4x smaller than on 3.44.9, but it is still unbounded: at the 300 ms sampling rate of our production app this is still ~0.75 GB/day.

  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 3, 2026
  5. suryaKommana2662 commented on Sep 3, 2026

    @suryaKommana2662
    Contributor

    @alesms Thanks for re-testing this on Flutter 3.47.2 and providing the detailed memory measurements. Routing this to the team-engine for further investigation.

  6. added
    engineflutter/engine related. See also e: labels.
    e: impellerImpeller rendering backend issues and features requests
    and removed
    in triagePresently being triaged by the triage team
    on Sep 3, 2026
  7. added
    P1High-priority issues at the top of the work list
    on Sep 14, 2026
  8. andywolff commented on Sep 15, 2026

    @andywolff
    Contributor

    I found a suspicious line: in snapshot_controller_impeller, DoMakeRasterSnapshot

    return impeller::DisplayListToTexture(
          display_list, render_target_size, *context,
          /*reset_host_buffer=*/false,
          /*generate_mips=*/true, impeller_pixel_format);

    I don't see a good reason why we are passing false for reset_host_buffer. In other callsites for DisplayListToTexture we pass true. I'll try setting that to true and see what happens

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work liste: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.team-engineOwned by Engine teamtriaged-engineTriaged by Engine team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions