I had a tough time understanding where memory leaks are coming from, especially on iOS safari. I'd go into Dev Tools > Timelines tab and see the memory go up, but not sure how or where. So I wrote this function to traverse all the global objects that have been added by various software, avoiding revisiting the same objects more than once. The function is async so as not to tie up the UX too much. You can run it to start seeing where the references are being leaked.
Q = {};
Q.globalNames = Object.keys(window); // snapshot baseline
Q.globalNamesAdded = function () {
const current = Object.keys(window);
const baseline = Q.globalNames;
const added = [];
for (let i = 0; i < current.length; i++) {
if (!baseline.includes(current[i])) {
added.push(current[i]);
}
}
return added;
};
Q.walkGlobalsAsync = function (filterFn, options = {}) {
const seen = new WeakSet();
const found = new Set();
const pathMap = new WeakMap();
const maxDepth = options.maxDepth || 5;
const includeStack = options.includeStack || false;
const logEvery = options.logEvery || 100;
const startingKeys = Q.globalNamesAdded
? Q.globalNamesAdded()
: Object.keys(window);
let totalChecked = 0;
let matchesFound = 0;
function walk(obj, path = 'window', depth = 0) {
if (!obj || typeof obj !== 'object') return;
if (seen.has(obj)) return;
seen.add(obj);
totalChecked++;
if (totalChecked % logEvery === 0) {
console.log(`Checked ${totalChecked} objects, found ${matchesFound}`);
}
if (filterFn(obj)) {
found.add(obj);
matchesFound++;
if (includeStack) {
pathMap.set(obj, path);
console.log(`[FOUND] ${path}`, obj);
} else {
console.log(`[FOUND]`, obj);
}
}
if (depth >= maxDepth) return;
const skipKeys = obj instanceof HTMLElement
? new Set([
'parentNode', 'parentElement', 'nextSibling', 'previousSibling',
'firstChild', 'lastChild', 'children', 'childNodes',
'ownerDocument', 'style', 'classList', 'dataset',
'attributes', 'innerHTML', 'outerHTML',
'nextElementSibling', 'previousElementSibling'
])
: null;
for (const key in obj) {
if (skipKeys && skipKeys.has(key)) continue;
try {
walk(obj[key], path + '.' + key, depth + 1);
} catch (e) {}
}
}
let i = 0;
function nextBatch() {
const batchSize = 10;
const end = Math.min(i + batchSize, startingKeys.length);
for (; i < end; i++) {
try {
walk(window[startingKeys[i]], 'window.' + startingKeys[i], 0);
} catch (e) {}
}
if (i < startingKeys.length) {
setTimeout(nextBatch, 0); // Schedule next batch
} else {
console.log(`Done. Found ${matchesFound} retained objects.`);
if (includeStack) {
console.log([...found].map(obj => ({
object: obj,
path: pathMap.get(obj)
})));
} else {
console.log([...found]);
}
}
}
nextBatch();
};
Here is how you use it:
Q.walkGlobalsAsync(
obj => obj instanceof HTMLElement && !document.contains(obj),
{ includeStack: true, maxDepth: 4, logEvery: 50 }
);
However -- note that this will NOT find objects retained by closures, even if you can find the closures themselves you're going to have to check their code manually.