You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Searched; #2006 is related but was closed as not planned and concerns a
different symptom. PR #2239 proposes the fix this report confirms.
Current Behaviour
On a physical device, rapid pinch in/out zooming makes memory grow without
bound until the OS kills the app. Memory that is not GPU texture memory
grows monotonically while the graphics category stays flat, which points at
decoded image bytes that are never freed rather than at texture pressure.
Expected Behaviour
Tiles that are pruned or replaced release their decoded image, and memory
stays flat during sustained zooming.
Root cause
ImageStreamCompleter.setImage hands every listener its own image.clone()
(painting/image_stream.dart:736-740), and RawImage clones again for its RenderImage (widgets/basic.dart:6997). Handing the image to the widget
tree therefore never transfers ownership: the handle belongs to TileImage.
TileImage releases it nowhere:
_onImageLoadSuccess (tile_image.dart:159-167) overwrites imageInfo
without disposing the previous frame;
dispose() (:216-245) removes the stream listener but never calls imageInfo?.dispose().
Every tile that is loaded and later pruned therefore leaks its decoded bytes
for the lifetime of the process — 4 MiB for a 512px tile at @2x
(1024x1024, RGBA8).
There is a third path: setImage iterates over a copy of the listener list,
so a listener removed in dispose() can still receive a frame. That image is
never painted and never owned — it leaks too.
How can we reproduce it?
Steps To Reproduce
TileLayer with 512px retina tiles (tileDimension: 512, @2x URL).
On a physical device, pinch-zoom in and out rapidly and continuously.
Sample adb shell dumpsys meminfo <pid> every 5 s.
Measurements
Samsung SM S938B (Android 16, dpr 3), Flutter 3.44.6, release build,
flutter_map 8.3.2. Continuous rapid pinch zooming, values in MB.
Before the fix:
t
Graphics
Private Other
TOTAL PSS
5-60
197-475
34-60
347-651
70 s
475
1129
1876
85 s
475
3230
4243
95 s
475
3387
7456
100s
app killed by the OS
Note the shape: Graphics freezes while Private Other explodes. In an
earlier run the peak was 6025 MB of Private Other.
After applying PR #2239's fix (plus the disposed-listener case above):
t
Graphics
Private Other
TOTAL PSS
5-130s
201-627
34-77
361-900
Stable for 130 s of continuous zooming, no growth trend, no kill. Graphics
now rises and falls with zoom levels instead of freezing.
Hypotheses ruled out by measurement
Smaller tiles (dropping @2x): memory halved, but Graphicsrose and
the app died sooner (~35 s vs ~50 s). The retain rule counts tiles, not
bytes, so smaller tiles simply let more be retained.
Built-in disk cache: with DisabledMapCachingProvider the explosion is
unchanged and Native Heap stays low, so the cache writer isolate is not
involved.
Forcing a layer reset (via TileLayer.reset) after each zoom: far worse
— the app died in ~20 s. removeAll goes through the same leaking dispose() and forces a full reload, multiplying the leak.
Reducing tile creation (panBuffer: 0, keepBuffer: 1, throttled tileUpdateTransformer): helps a lot — the stable window grew from ~10 s to
60 s — but only lowers the slope, confirming the leak itself is untouched.
Version
flutter_map 8.3.2 (also present on master as of 2026-09-04), Flutter 3.44.6
stable, Dart 3.12.2, Android 16 release build.
Do you have a potential solution?
Yes — PR #2239 already implements this fix, and I can confirm it works.
I applied its changes on top of v8.3.2 and measured the result on a physical device: memory stays flat (Private Other 34-77 MB) through 130+ seconds of continuous rapid zooming, where the unpatched build was killed by the OS after ~50 seconds.
One detail worth highlighting, because it is in the PR's code but not in its description: besides releasing the handle on frame replacement and in dispose(), the third case matters in practice — setImage iterates over a copy of the listener list, so a listener removed in dispose() can still receive a frame. That image is never painted and never owned, so it must be released in place. With panBuffer: 0 and fast pinch gestures this path is hit regularly, not rarely.
The PR's CI is currently red only on "Analyse Code", which fails at dart format --set-exit-if-changed — the test jobs pass on both Flutter channels.
What is the bug?
Is there an existing issue for this?
Searched; #2006 is related but was closed as not planned and concerns a
different symptom. PR #2239 proposes the fix this report confirms.
Current Behaviour
On a physical device, rapid pinch in/out zooming makes memory grow without
bound until the OS kills the app. Memory that is not GPU texture memory
grows monotonically while the graphics category stays flat, which points at
decoded image bytes that are never freed rather than at texture pressure.
Expected Behaviour
Tiles that are pruned or replaced release their decoded image, and memory
stays flat during sustained zooming.
Root cause
ImageStreamCompleter.setImagehands every listener its ownimage.clone()(
painting/image_stream.dart:736-740), andRawImageclones again for itsRenderImage(widgets/basic.dart:6997). Handing the image to the widgettree therefore never transfers ownership: the handle belongs to
TileImage.TileImagereleases it nowhere:_onImageLoadSuccess(tile_image.dart:159-167) overwritesimageInfowithout disposing the previous frame;
dispose()(:216-245) removes the stream listener but never callsimageInfo?.dispose().Every tile that is loaded and later pruned therefore leaks its decoded bytes
for the lifetime of the process — 4 MiB for a 512px tile at
@2x(1024x1024, RGBA8).
There is a third path:
setImageiterates over a copy of the listener list,so a listener removed in
dispose()can still receive a frame. That image isnever painted and never owned — it leaks too.
How can we reproduce it?
Steps To Reproduce
TileLayerwith 512px retina tiles (tileDimension: 512,@2xURL).adb shell dumpsys meminfo <pid>every 5 s.Measurements
Samsung SM S938B (Android 16, dpr 3), Flutter 3.44.6, release build,
flutter_map 8.3.2. Continuous rapid pinch zooming, values in MB.
Before the fix:
Note the shape:
Graphicsfreezes whilePrivate Otherexplodes. In anearlier run the peak was 6025 MB of
Private Other.After applying PR #2239's fix (plus the disposed-listener case above):
Stable for 130 s of continuous zooming, no growth trend, no kill.
Graphicsnow rises and falls with zoom levels instead of freezing.
Hypotheses ruled out by measurement
@2x): memory halved, butGraphicsrose andthe app died sooner (~35 s vs ~50 s). The retain rule counts tiles, not
bytes, so smaller tiles simply let more be retained.
DisabledMapCachingProviderthe explosion isunchanged and
Native Heapstays low, so the cache writer isolate is notinvolved.
TileLayer.reset) after each zoom: far worse— the app died in ~20 s.
removeAllgoes through the same leakingdispose()and forces a full reload, multiplying the leak.Gestures with very close start/end points causes division by zero #2221, but the NaN path it describes is still present in 8.3.2.)
panBuffer: 0,keepBuffer: 1, throttledtileUpdateTransformer): helps a lot — the stable window grew from ~10 s to60 s — but only lowers the slope, confirming the leak itself is untouched.
Version
flutter_map 8.3.2 (also present on master as of 2026-09-04), Flutter 3.44.6
stable, Dart 3.12.2, Android 16 release build.
Do you have a potential solution?
Yes — PR #2239 already implements this fix, and I can confirm it works.
I applied its changes on top of v8.3.2 and measured the result on a physical device: memory stays flat (Private Other 34-77 MB) through 130+ seconds of continuous rapid zooming, where the unpatched build was killed by the OS after ~50 seconds.
One detail worth highlighting, because it is in the PR's code but not in its description: besides releasing the handle on frame replacement and in dispose(), the third case matters in practice — setImage iterates over a copy of the listener list, so a listener removed in dispose() can still receive a frame. That image is never painted and never owned, so it must be released in place. With panBuffer: 0 and fast pinch gestures this path is hit regularly, not rarely.
The PR's CI is currently red only on "Analyse Code", which fails at
dart format --set-exit-if-changed— the test jobs pass on both Flutter channels.