Third-Party Library Memory Leaks
Framework cleanup covers the DOM and state your framework created. It does not cover the chart library that attached a resize observer, the map that allocated WebGL textures and a tile worker, the rich-text editor that registered selection listeners on document, or the tag manager that keeps appending to a data layer. This topic, part of Framework-Specific Memory Optimization, is for frontend engineers integrating imperative libraries into React, Vue, Angular, Svelte or Solid apps: how these libraries hold memory, the destroy contracts they expect you to honour — for charts, maps, rich-text editors and analytics tags — and how to prove an integration is clean.
Conceptual Grounding
An imperative library instance is a small world of its own. When you call new Chart(canvas, config), L.map(el), new mapboxgl.Map(...) or an editor’s create(el), the library typically: builds DOM inside (and sometimes outside) the element you passed; attaches listeners to that element, to window or to document; creates observers (ResizeObserver, IntersectionObserver, MutationObserver); starts timers or animation loops; allocates canvases, WebGL contexts, textures and buffers; spawns web workers; and registers the instance in a module-level registry so it can find instances later (for tooltips, global resize handling or plugin coordination). Every one of those is a reference path that can keep the instance — and the DOM element you passed it — alive.
Your framework removes the host element when the component unmounts. It cannot remove the library’s listeners on window, stop its animation frame loop, terminate its worker, delete its GPU textures, or remove its registry entry. Only the library’s own teardown method — destroy(), dispose(), remove(), unmount() — does that, and only if you call it. A component that creates an instance on mount and never destroys it leaks the entire instance on every mount; if the component re-creates the instance on prop changes, it leaks on every change. The detached element shows up in heap snapshots as detached DOM, with a retainer path through the library’s registry or a global listener; the GPU side shows up only in the Task Manager’s GPU memory column, as described in WebGL texture and GPU memory leaks.
A second category of third-party memory growth is not about instances at all: scripts that accumulate state over the session. Analytics libraries, tag managers, session-replay tools, A/B testing SDKs and chat widgets keep queues, data layers, DOM mutation logs and retry buffers. They run in your page’s heap, they grow with user activity, and they are often loaded by teams outside engineering. Measuring them is part of memory work too.
Leaks from third-party code are also harder to notice than leaks in your own code, because nobody on the team reads the library’s source when a view is closed. The symptoms surface indirectly — a dashboard that feels slower after lunch, a mobile tab that reloads, a WebGL warning in the console — long after the integration was written. That is why integrations need explicit tests and budgets rather than trust.
The practical model is simple: every library instance has exactly one owner, and the owner’s teardown calls the library’s teardown. The rest of this topic is about making that true in every framework and verifying it.
Diagnostic Workflow
- Inventory imperative libraries. List every library that receives a DOM element or creates UI imperatively (charts, maps, editors, players, date pickers, virtual keyboards) and every third-party script loaded on the page. Expected output: a table of libraries with their teardown method name. Metric: number of integrations without a documented teardown call.
- Cycle each integration. Mount and unmount the component that hosts the library twenty times in a production build, with a clean browser profile. Expected output: a repeatable scenario per library.
- Measure three signals. After forced GC, record JS heap, detached DOM count (heap snapshot filtered by
Detached) and — for canvas/WebGL libraries — GPU memory from the Task Manager. Metric: growth per cycle for each signal. - Read the retainers. For growing detached nodes, follow retainers to the library object that holds them: a registry array, a global listener, an observer, a worker message handler. Expected output: the specific path, which tells you whether teardown was missing or incomplete.
- Add or fix teardown in the owner. Call the library’s teardown in the component’s cleanup, and also handle re-creation on prop changes. Expected output: one live instance per mounted component.
- Re-measure and record. Repeat steps 2–3. Metric: zero growth per cycle across heap, detached DOM and GPU memory; record results next to the integration.
Code Patterns & Signatures
Use case: a framework-agnostic lifecycle wrapper. Every integration exposes the same mount/update/destroy contract, so framework glue is trivial and teardown is never forgotten.
// integrations/chart.js — one owner, one instance, one destroy
export function mountChart(el, config) {
let chart = new Chart(el, config);
return {
update(next) {
chart.data = next.data; // update in place instead of re-creating
chart.update('none');
},
destroy() {
chart?.destroy(); // library teardown: listeners, observers, canvas state
chart = null;
},
};
}
Use case: React glue for the wrapper.
function ChartView({ config }) {
const ref = useRef(null);
const handle = useRef(null);
useEffect(() => {
handle.current = mountChart(ref.current, config);
return () => handle.current.destroy(); // runs on unmount
}, []); // create once
useEffect(() => { handle.current?.update(config); }, [config]); // update, don't recreate
return <canvas ref={ref} />;
}
Use case: a development guard that counts live instances.
// Count instances created and destroyed through wrappers in development
export const live = new Map();
export function tracked(name, handle) {
live.set(name, (live.get(name) || 0) + 1);
const destroy = handle.destroy;
handle.destroy = () => { live.set(name, live.get(name) - 1); destroy(); };
return handle;
}
Symptom-to-Fix Reference
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Detached canvas/map containers grow per mount | Instance never destroyed; library registry retains it | Call destroy()/remove() in the owner’s cleanup |
Detached nodes return to baseline |
| GPU memory climbs with navigation | WebGL contexts and textures of old instances | Dispose instances; reuse one where possible | GPU memory flat |
window resize handlers accumulate |
Library-attached listeners never removed | Library teardown; check listener counts | Constant listener count |
| Instances re-created on every prop change | Effect depends on config and recreates | Create once; call the library’s update API | One instance per component |
| Page memory grows with session length, not with navigation | Analytics/data-layer/session tools accumulating | Measure third-party heap share; configure limits | Growth attributed and bounded |
| Worker count rises | Library spawns workers per instance | Destroy instances; share workers if supported | Worker count constant |
Edge Cases & Gotchas
Options objects that capture application state
Callbacks passed in options — formatters, click handlers, renderers — are closures that the library keeps for the life of the instance. If they capture large application state, the instance retains it. Keep callbacks thin and read current data from refs, stores or arguments, and replace options through the update API when data changes.
Instances created in loops
Dashboards that render a chart or mini-map per row of a list create dozens of instances at once, each with its own canvas, observers and possibly WebGL context. Virtualise such lists so only visible rows have live instances, and destroy instances for rows that scroll away, or render static images for non-interactive previews.
Teardown that is incomplete
Some library versions forget to remove a global listener or registry entry even when destroy() is called. When the retainer path still runs through library internals after a correct teardown call, upgrade the library, report the bug with a minimal reproduction, and as a stopgap reuse a single instance rather than creating new ones.
Async initialisation races
Libraries that load assets or workers asynchronously may finish initialising after the component has unmounted. If teardown ran before initialisation completed, the late-arriving setup can re-register listeners. Guard with a destroyed flag and destroy immediately if initialisation finishes after unmount.
Lazy-loaded library code
Code-splitting a heavy library reduces initial load, but once loaded, its module state stays for the session. Module-level caches in the library (tile caches, glyph caches) are sized by the library; check whether they are configurable.
Iframe-based widgets
Payment, video and social embeds often use iframes. Removing the iframe releases its document only if nothing references its window, as covered in detached iframes and leaked window objects.
Server rendering
Imperative libraries generally cannot run on the server. Guard creation so it only happens in the browser (effects, onMount), which also ensures teardown runs in the same environment.
Framework Glue: Where Teardown Belongs
The wrapper contract — mount(el, options) returning { update, destroy } — keeps library code framework-agnostic, and each framework then needs only a few lines of glue. What differs is where the teardown call lives.
In React, create the instance in an effect with an empty dependency list and return () => handle.destroy(); apply option changes in a second effect that calls update. Avoid putting options in the creation effect’s dependencies, which would destroy and re-create the library on every change. Under Strict Mode, the creation effect runs twice in development — a correct destroy makes that harmless, and an incorrect one shows up immediately, as described in React Strict Mode double effects and leak detection.
In Vue, create the instance in onMounted and destroy it in onBeforeUnmount (while the element still exists), or package the logic in a composable that registers onScopeDispose. Store the instance in a shallowRef or a plain variable — never a deep ref — because making a library’s internal object graph reactive wraps thousands of objects in proxies and can break the library. Components cached by <KeepAlive> are deactivated rather than unmounted, so pause expensive work in onDeactivated.
In Angular, create the instance in ngAfterViewInit (or an afterNextRender callback) and destroy it in ngOnDestroy or via inject(DestroyRef).onDestroy(...). Run the library outside Angular’s zone (NgZone.runOutsideAngular) if it fires frequent events such as animation frames, which avoids triggering change detection on every tick and reduces allocation.
In Svelte, an action (use:chart={options}) is the natural wrapper: return { update, destroy } from the action and Svelte calls them at the right times. In Solid, create the instance in onMount and register onCleanup(() => handle.destroy()) in the same component, keeping ownership rules from SolidJS createRoot and owner cleanup in mind.
Whatever the framework, two rules prevent most bugs: create once per mount, and destroy in the unmount path — not in a “close” handler that some unmounts bypass.
Testing Integrations in CI
Library integrations deserve their own memory tests because library upgrades change behaviour without any change in your code. A useful pattern is a dedicated test page per integration that mounts the component, runs a representative interaction (load data, zoom, type, change options), unmounts it, and repeats twenty times. After forced garbage collection, the test asserts that JS heap growth, detached DOM count and — where relevant — WebGL context count stay within small thresholds. Playwright can read heap usage through a DevTools Protocol session, and Memlab can run the same scenario and print retainer traces when it fails, as shown in finding leaks with Memlab scenarios.
Run these tests on every dependency update that touches a UI library. Many real incidents come from a minor version that stopped removing a global listener or started caching more aggressively; catching them in the pull request that bumps the version is far cheaper than finding them in production memory graphs weeks later.
Choosing Libraries With Memory in Mind
When evaluating a library, check four things before adopting it. A documented teardown method that removes listeners, observers, timers and workers — and, for canvas or WebGL libraries, releases GPU resources. An update API that changes data or options in place, so you are not forced to destroy and re-create on every change. Configurable caches for tiles, glyphs, images or history, with sensible defaults. And framework wrappers that tie teardown to unmount, maintained alongside the library. A quick test — mount and destroy the library’s demo twenty times in a blank page while watching heap, detached nodes and GPU memory — reveals most problems in minutes and is worth doing before the library is woven into a large codebase.
For third-party scripts that are not components — analytics, tag managers, chat and session tools — ask for the equivalent: bounded queues, configurable sampling, and a way to stop or pause collection. Measure their share of the page’s heap in a clean profile with and without them loaded; if a vendor script accounts for tens of megabytes after a long session, that is a product decision to make with data, not an unavoidable cost.
Frequently Asked Questions
Why doesn’t my framework clean up third-party libraries?
Frameworks only know about the DOM and state they create. A library instance’s listeners, observers, timers, workers, GPU resources and registries are invisible to them. Call the library’s own teardown method from the component’s cleanup so both halves are released.
Should I destroy and re-create a chart when its data changes?
No. Use the library’s update API to change data or options in place. Re-creating on every change multiplies allocation and, if any teardown step is missed, multiplies leaks. Create once per mount, update many times, destroy once on unmount.
How do I know if a library leaks even when I call destroy?
Mount and destroy it repeatedly in a minimal page and compare heap snapshots. If detached DOM or library objects still accumulate, follow their retainers into the library’s code; a path through its internal registry or global listeners indicates a library bug.
Do analytics and tag manager scripts cause memory leaks?
They can grow memory over long sessions through data layers, event queues and DOM observation. Measure their contribution in a clean profile, configure limits or sampling where offered, and remove tags that no longer serve a purpose.
Why does GPU memory not show up in my heap snapshot?
Canvas pixel buffers, WebGL textures and video frames live outside the JavaScript heap, often in the GPU process. Heap snapshots only show their small JavaScript handles. Use Chrome’s Task Manager with the GPU memory column enabled, or process-level metrics, to measure libraries that render with canvas or WebGL.
Can I reuse one library instance across views instead of destroying it?
Often yes, and for expensive libraries such as maps it can be the best option: create one instance, move or show its container as needed, and update its data. That makes memory constant rather than zero between uses, so it suits libraries used frequently; for rarely used ones, destroy on unmount.
How do I handle libraries without a destroy method?
Check the documentation and source for equivalents such as remove, dispose, teardown or unmount. If there is truly none, reuse a single instance for the page, avoid creating it repeatedly, and consider isolating it in an iframe that you can remove as a whole — or choose a library that supports proper teardown.
What is the best way to wrap libraries across frameworks?
A small integration module per library with mount(el, options) returning { update, destroy }. Framework components then only need to call mount on mount, update on changes and destroy on unmount, which works the same way in React, Vue, Angular, Svelte and Solid.
Related
- Destroying Chart Instances in Chart.js and ECharts — chart teardown and updates
- Map Library Instance Memory in Leaflet and Mapbox — maps, tiles and WebGL
- Rich-Text Editor Instance Leaks — editors, history and document listeners
- Analytics and Tag Manager Memory Growth — scripts that grow with the session
- Framework-Specific Memory Optimization — the parent section