Repository navigation
Description
Steps to reproduce
- Run the code sample below on an iOS simulator (observed on iPhone 17 Pro, iOS 26.0) in debug or profile mode.
- 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 returnedui.Imageis disposed after every call. - 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:
- Observed and measured on the iOS simulator (
MTLSimDriver, so the buffers are host RAM). TheHostBuffer/AllocatorMTLcode path is not simulator-specific, so physical devices may leak GPU-visible memory the same way — not yet verified on hardware. - Not a duplicate of Memory Leak: WidgetInspectorServiceExtensions.screenshot does not dispose created ui.Image. #135824: the
ui.Imagereturned to Dart is disposed here; the retained memory is internal to the engine's snapshot rendering.
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
suryaKommana2662 commentedon Sep 1, 2026
@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
alesms commentedon Sep 3, 2026
@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 withfootprint <pid>(phys_footprint) every 60 s for 20 minutes.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.
footprintcategory breakdown, first vs last sample: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.
suryaKommana2662 commentedon Sep 3, 2026
@alesms Thanks for re-testing this on Flutter 3.47.2 and providing the detailed memory measurements. Routing this to the
team-enginefor further investigation.andywolff commentedon Sep 15, 2026
I found a suspicious line: in snapshot_controller_impeller, DoMakeRasterSnapshot
I don't see a good reason why we are passing
falseforreset_host_buffer. In other callsites for DisplayListToTexture we passtrue. I'll try setting that to true and see what happens