Repository navigation
ImageManager.updatedImages is write-only: every style switch with a sprite permanently inflates per-frame cost (fps stairs 60→30→20→12 until page reload) #8052
Description
Summary
ImageManager#updatedImages is written in exactly one place and never cleared anywhere — not per frame, not in removeImage(), not even in destroy(). ImageAtlas#patchUpdatedImages enumerates this dictionary per tile, per frame. Because Style#_loadSprite routes every already-known icon id through the updateImage() branch, returning to a previously visited style (plain map.setStyle(url) on the default diff path) marks that style's entire sprite as updated, forever.
On a map that switches between two styles with large sprites, the set saturates after one or two round-trips and every subsequent frame pays a full enumeration of ~2000 dead entries on every in-view vector tile. On our production map (RTX 5060 Ti, 2544×1220 canvas, ~70 vector tiles in a pitch-60 view) this permanently degrades a resting 60/30 fps scene to a 30/20/12 fps vsync staircase until the page is reloaded. Reload heals it because a rebuilt Style gets a fresh ImageManager.
We believe this is unreported: searching this repo for patchUpdatedImages in issues/PRs returns zero results. PR #7954 (animated icons) touches the same code path but does not clear the set either.
Environment
- maplibre-gl-js 6.0.0 (dist); the same code shape is present in
mainand at least as far back as 4.7.1 - Chromium 140-level (Brave) / Windows 11 / RTX 5060 Ti — but the mechanism is engine-independent
Steps to reproduce
- Load style A that has a sprite (we used a basemap style whose sprite has 1665 icons; any large sprite works).
map.setStyle(styleB)where B has a different sprite URL (ours had 264 icons), thenmap.setStyle(styleA)again. Default options — the diff path keeps the sameStyle/ImageManagerinstance.- Rotate the camera in a tile-heavy view (high pitch makes it obvious) and watch frame times.
- Inspect
Object.keys(map.style.imageManager.updatedImages).length— after step 2 it equals |spriteA| and never decreases; after one more round-trip it is |spriteA| + |spriteB|. - Control: run the same camera work without any
setStyle— no degradation, confirming the cost is the stale set, not the animation. Settingmap.style.imageManager.updatedImages = {}on an idle, degraded map instantly restores the original frame times (we measured the per-framegetImagecall count going from ~82k/frame to 0).
Analysis (with code references, 6.0.0 dist ≡ main sources)
Write side — src/render/image_manager.ts:
updateImage(id, image, validate = true) {
// ...
image.version = oldImage.version + 1;
this.images[id] = image;
this.updatedImages[id] = true; // <-- only write; no code path ever deletes or resets this
}beginFrame() clears only callbackDispatchedThisFrame. removeImage() deletes from images and patterns but not from updatedImages. destroy() doesn't touch it either.
Read side — src/render/image_atlas.ts, called from Painter.render → tileManager.prepare → tile.prepare for every in-view tile that has an imageAtlas, every frame:
patchUpdatedImages(imageManager, texture) {
imageManager.dispatchRenderCallbacks(this.haveRenderCallbacks);
for (const name in imageManager.updatedImages) {
this.patchUpdatedImage(this.iconPositions[name], imageManager.getImage(name), texture);
this.patchUpdatedImage(this.patternPositions[name], imageManager.getImage(name), texture);
}
}patchUpdatedImage early-returns on position.version === image.version, so the steady-state cost is pure enumeration + 2 × getImage per entry per tile per frame — no texture uploads (we verified GPU texture counts stay flat while fps degrades). A micro-benchmark of this exact loop shape gives ~0.24 ms per atlas-carrying tile per frame at ~1900 entries; at 60–120 such tiles that is 15–30 ms/frame, which matches the measured staircase. Note that worker-built imageAtlas is attached to every vector tile regardless of whether it uses any icons, so even icon-free overlay sources pay full price.
Filling side — src/style/style.ts, _loadSprite: every id already present in imageManager.images goes through the updateImage branch, so a sprite reload marks the whole sprite updated. And ids are practically always already present because of a second bug:
Related bug: dead cleanup in _loadSprite
this._spritesImagesIds[spriteId] = []; // <-- reset FIRST...
const imagesToRemove = this._spritesImagesIds[spriteId]
? this._spritesImagesIds[spriteId].filter(id => !(id in images)) // <-- ...then filter the now-empty array
: [];imagesToRemove is always [], so icons of a replaced sprite are never removed from the ImageManager (also note the predicate compares icon ids against sprite namespace keys, which would be wrong even if the array weren't empty). This guarantees the updateImage branch on every style return and leaks decoded RGBA of all previously seen sprites. Style#setSprite only calls _unloadSprite() when the new style has no sprite at all.
Third, minor: wasted synchronous decode in updateImage
updateImage calls this.getImage(id) only to read .version — and getImage force-decodes spriteData via canvas getImageData for a record that the very next line replaces. On a sprite reload that is up to |sprite| wasted synchronous canvas readbacks inside the style-switch window. const oldImage = this.images[id] would avoid it.
Suggested fixes
- Clear
updatedImagesonce all tile atlases have been patched (or track a per-atlas patched version), anddelete this.updatedImages[id]inremoveImage(). - Fix the dead
imagesToRemovefilter in_loadSprite(capture the previous array before resetting; de-namespace ids before theincheck). NoteaddImagethrows on an existing id, so fix 2 should land together with tests over style-switch round-trips. - In
updateImage, read the old version withoutgetImage's decode side effect.
We're happy to provide more measurements (CPU profiles showing patchUpdatedImages at 44% self-time in the degraded state and absent after reload) or test the fix against our reproduction.
Activity
HarelM commentedon Jul 29, 2026
Thanks for opening this issue.
Please read our AI policy.
If you think there's something worth fixing a PR is a much better tool for that specific problems...
77-1812-MC commentedon Aug 1, 2026
Thank you for the reply, and I've read the AI policy - I owe you a disclosure I should have made upfront: I'm not a programmer. I build my sound-map website with the help of AI (Claude), and both the original issue text and this reply were drafted by it. I apologize for not marking that clearly from the start.
What I can vouch for personally is that the problem is real: the measurements in the issue come from my actual production map, where switching styles a few times permanently drops a smooth 60 fps scene to a choppy one until the page is reloaded, and clearing updatedImages manually instantly heals it.
Regarding a PR: honestly, I'm not able to author one myself, and I don't want to submit AI-written code under my name that I couldn't defend in review - that seems to be exactly what your policy is against.
HarelM commentedon Aug 7, 2026
Can you have the AI generate a minimal reproduction of this issue using the latest version? I've seen a few PRs related to this area recently, so there might be an open PR to solve this already...
Bottom line, even for an AI, following the issue template is the best approach.
HarelM commentedon Aug 8, 2026
HarelM commentedon Aug 8, 2026
@lucaswoj you just recently changed code in this area, is the linked PR and your work solves this issue?
77-1812-MC commentedon Aug 8, 2026
Thanks for looking at this! I had the AI dig into both questions; same disclosure as before — the analysis and text below are AI-drafted (Claude), I ran the measurements myself on my machine.
Does #8094 solve this one?
Unfortunately no — related in name only:
ImageSource/CanvasSource/VideoSource):src/source/*_source.ts+src/webgl/texture.ts.ImageManager, its per-frame consumerImageAtlas.patchUpdatedImages(), and the sprite-reload path inStyle._loadSprite(). fix: stop image sources leaking a GPU texture per update and on removal #8094 touches none of those files — the similarity is the method name (ImageSource.updateImage()vsImageManager.updateImage()).patchUpdatedImagesenumeration.On current
main(871bb78, i.e. after #8094 and after #7954) the mechanism is unchanged:removeImage()doesn't delete the entry either;imagesToRemovecleanup that guarantees theupdateImagebranch on every style return: style.ts#L542-L546 (the array is reset on L542, then the filter on L545 reads the freshly-emptied array → always[]).#7954 doesn't clear the dictionary either — for its own (legitimate) purposes it adds one more write path (
addImage()now routes WebGL-backed images throughupdateImage()). We also couldn't find any open PR that clears it, and no PR references this issue.Minimal reproduction on 6.2.0 (latest release)
maplibre-gl-js version: 6.2.0 (the release changes none of the three code sites above — verified against the dist bundle) · browser: Chromium 140-level (Edge/Brave), Windows 11 — the mechanism itself is engine-independent.
One self-contained page — the full source is in the collapsed section at the bottom of this comment (happy to also put it on jsbin if a clickable link is easier). Design: style A = OpenFreeMap Liberty (sprite: 264 icons as loaded); style B = the same style object with only the sprite URL swapped (Carto's), so
map.setStyle()stays on the default diff path and the only diff operation issetSprite— isolating the mechanism (switching between two entirely different styles does the same thing). The HUD shows theupdatedImagessize,ImageManager.getImage()calls per rendered frame (the per-stale-entry × per-tile workpatchUpdatedImagesdoes), and frame time;map.repaint = truekeeps the render loop hot the way a continuous pan/rotate would.Steps: open the page (a 1440×900 window gives ~32 atlas-carrying tiles at pitch 60), wait for load, click "Style round-trip A→B→A" once, compare the HUD; then click "Heal".
Expected: once the sprite reload settles and every in-view atlas has been patched, per-frame work returns to the pre-switch level (no permanent per-frame cost from a completed style switch).
Actual (measured):
updatedImagesentriesgetImage()calls / frameupdatedImages = {}on an idle map)The counters are hardware-independent; the wall-clock cost scales with entries × tiles. At this small repro scale a desktop GPU still hides it inside the 60 fps budget, but the identical run under software rendering already shows the EMA frame time recovering 41.8 → 35.9 ms at the moment of "Heal". At our production scale — two real sprites totalling 1929 ids and ~70 tiles at pitch 60 — this is exactly the 60→30→20→12 staircase from the report, and a page reload heals it because a rebuilt
Stylegets a freshImageManagerrepro.html (self-contained, maplibre-gl@6.2.0 from unpkg)
Happy to re-run this repro (and our production-scale measurement) against any candidate fix PR.
HarelM commentedon Aug 8, 2026
The attached HTML is a bad minimal reproduction as I have no clue what the problem is and what it should be showing without reading a lot of code and trying to understand what the AI wrote here.
If this is something that you need fixed, I suggest hire someone for a short period of time to solve this.
Sorry...
77-1812-MC commentedon Aug 8, 2026
No problem, I don't need it fixed. I posted it in case it was helpful to you as a developer. I understand it isn't, so I won't post more of these. Claude fixes such issues for me on the fly and recheck them against each new Maplibre release, so I'm fine either way. Thanks for your time.
HarelM commentedon Aug 11, 2026
⬆️ Update dependency maplibre-gl to ^6.4.0
⬆️ Update dependency maplibre-gl to ^6.4.1
⬆️ Update dependency maplibre-gl to ^6.4.1 (#2086)