Profiling Memory Without Browser Extensions Skewing Results
Your heap snapshots contain objects your code never created, and a “leak” disappears when a colleague profiles the same build — the usual culprit is a browser extension, and this guide from Mastering the Chrome DevTools Memory Tab, part of Browser DevTools & Performance Profiling Workflows, shows how to remove that noise before you measure anything.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Snapshot contains chrome-extension:// script names |
Content scripts run in the page’s renderer | Profile in a clean profile or incognito window | Removes 5–60 MB of foreign heap per page |
| Detached DOM trees you cannot find in your code | Extensions clone or annotate page DOM (password managers, translators) | Retry with extensions disabled; compare counts | Detached node count drops to your code’s true value |
| Memory grows on every navigation even on a blank page | An extension keeps per-tab state that grows | Measure about:blank baseline in the same profile |
Separates environment growth from app growth |
| Heap totals differ by 20+ MB between teammates | Different installed extensions | Standardise a profiling profile via --user-data-dir |
Reproducible numbers across machines |
| Allocation profile shows unknown hot functions | Extension scripts allocate on DOM mutation events | Filter the JavaScript context dropdown to the main frame | Top allocators all belong to your code |
Root Cause: Extensions Share Your Page’s Renderer
A Chrome extension’s content scripts are injected into every matching page and execute in the same renderer process as your code. They run in an isolated world — a separate JavaScript global object with its own prototypes — but they share the underlying V8 isolate and the same DOM. That has three consequences for memory profiling.
First, the extension’s own objects live in the same heap you are snapshotting. Ad blockers keep large filter structures in memory; grammar checkers and translators hold copies of text they have analysed; password managers keep references to form fields. Depending on the extension, that can be anywhere from a few megabytes to tens of megabytes per tab, and it appears in the summary view alongside your constructors.
Second, extensions hold references to your DOM. A content script that attaches a MutationObserver to document.body, or caches a reference to the input element it decorated, will keep that node alive after your framework removes it. In a snapshot comparison those nodes appear as detached DOM trees, and their retainer path leads into code you did not write. Teams lose days trying to fix a leak that only exists on the machine of the person who reported it.
Third, extensions allocate in response to the same events your page triggers. Every DOM mutation your app makes may wake an extension’s observer, which then allocates to process the change. In an allocation sampling profile, those allocations inflate totals and can push a foreign function to the top of the Heavy view.
DevTools gives you a partial filter — the JavaScript context dropdown at the top of the Memory panel lets you select the main frame’s context — but it does not remove the extension’s retention of your DOM, and the shared heap still affects GC timing. The only reliable fix is to measure in an environment with no extensions at all, then use the context filter as a second line of defence.
Step-by-Step Fix
- Create a dedicated profiling profile. Launch Chrome with an empty user data directory, for example
google-chrome --user-data-dir=/tmp/chrome-profiling --no-first-run. This profile has no extensions, no synced state and no cached service workers. Verification:chrome://extensionsin that window lists nothing. - Or use an incognito window as a quick check. Extensions are disabled in incognito unless explicitly allowed. It is fine for a fast comparison, but a dedicated profile is more reproducible because incognito still honours per-extension “Allow in Incognito” toggles. Verification: the puzzle-piece icon shows no active extensions.
- Measure the environment baseline. In the clean profile, open
about:blank, open DevTools → Memory, and take a heap snapshot. Then load your app and take another. Verification: theabout:blanksnapshot is a few MB; everything above it after loading your page is attributable to your page. - Select the main-frame context in the Memory panel. Use the Select JavaScript VM instance list at the bottom of the profiling-type selector (or the context dropdown in the toolbar) and pick your page’s top frame rather than “all”. Verification: no
chrome-extension://URLs appear in the snapshot’s Summary view when you filter by “extension”. - Re-run the suspected leak scenario. Repeat the flow that showed growth — for example open and close a modal ten times — and diff two snapshots with the Comparison view. Verification: if the delta disappears in the clean profile, the “leak” was an extension; if it remains, the retainer chain now points only into your code.
- Record the environment in the bug report. Note the Chrome version, the launch flags and that extensions were disabled. Verification: a teammate can reproduce the same numbers within ±5% using the same command.
Command and Code Reference
Use case: launch a reproducible, extension-free Chrome for manual profiling. Keeping the flags in a script means every engineer measures the same environment.
#!/usr/bin/env bash
# profile-chrome.sh — start Chrome with a throwaway, extension-free profile
PROFILE_DIR="$(mktemp -d /tmp/chrome-mem-XXXX)"
# --user-data-dir: empty profile, so no extensions and no sync
# --disable-extensions: belt and braces, even if one is side-loaded
# --disable-background-networking: fewer background allocations
google-chrome \
--user-data-dir="$PROFILE_DIR" \
--no-first-run \
--disable-extensions \
--disable-background-networking \
"http://localhost:5173/"
rm -rf "$PROFILE_DIR" # nothing leaks between sessions
Use case: detect foreign objects in a snapshot programmatically. When you receive a .heapsnapshot from someone else, count nodes whose script URL comes from an extension before trusting any conclusion drawn from it.
// count-extension-nodes.mjs — node count-extension-nodes.mjs trace.heapsnapshot
import { readFileSync } from 'node:fs';
const snap = JSON.parse(readFileSync(process.argv[2], 'utf8'));
// The strings table holds every name and URL referenced by the snapshot
const extStrings = snap.strings.filter((s) => s.startsWith('chrome-extension://'));
console.log(`extension URLs referenced: ${extStrings.length}`);
if (extStrings.length) {
// Print a few so you can identify the extension from its ID
console.log([...new Set(extStrings.map((s) => s.split('/')[2]))].slice(0, 5));
console.log('Re-capture this snapshot in a clean profile before analysing it.');
}
Verification and Regression Prevention
The fix is verified when your baseline is stable and attributable: about:blank in the profiling profile snapshots at a few MB, your app’s post-load snapshot is reproducible within ±5% across three runs and across two machines, and the Comparison view after a repeated flow contains only constructors from your own bundle. Any detached DOM node you still see now has a retainer chain you can act on.
To stop extension noise creeping back in, never run automated memory tests in a developer’s everyday profile. Headless Chrome launched by Puppeteer or Playwright already starts without extensions, which is one reason automated leak detection in CI produces steadier numbers than manual checks. For manual work, commit the launch script above to the repository, document it in your profiling runbook, and ask bug reporters to attach the snapshot-count script’s output so foreign objects are ruled out before anyone starts reading retainers.
When the leak really is an extension
Sometimes the clean-profile test proves the growth belongs to an extension your users commonly run — a popular password manager, a grammar checker, an enterprise-mandated security plug-in. You cannot fix their code, but you can reduce how much of your page they hold. Extensions that observe the DOM retain whatever they have been given the chance to annotate, so pages that recycle a small set of nodes (for example through list virtualisation) leak far less through extensions than pages that create and discard thousands of form fields. Avoid re-creating inputs on every render when updating their value would do, give long-lived containers stable identities, and keep sensitive or large content out of attributes that scanners read. Record the extension and version in your issue tracker, reproduce the growth with only that extension enabled, and report it upstream with a snapshot attached — extension authors fix retention bugs quickly when given a retainer path.
It is also worth knowing the size of the effect on your real audience. Field memory measurement with performance.measureUserAgentSpecificMemory() reports memory attributed to your page, and comparing its distribution against lab numbers from the clean profile tells you how much headroom extensions consume on typical user machines. If the median field figure is 40% above the clean-profile figure, your memory budget for low-end devices must leave room for that overhead.
Frequently Asked Questions
Is incognito mode enough to exclude extensions?
Usually, but not always. Extensions are off in incognito unless a user has enabled “Allow in Incognito” for them, and some developers have done that for password managers or debugging tools. A fresh --user-data-dir profile is the only environment guaranteed to have no extensions.
Can I just filter extension objects out of the snapshot?
Selecting your main frame’s JavaScript context removes the extension world’s own objects from view, but it cannot undo the extension’s references to your DOM. Nodes it retains still appear as detached, and GC timing is still affected, so filtering is a supplement to a clean profile rather than a replacement.
Do extensions affect Node.js memory profiling?
No. Node.js has no extension mechanism in the browser sense, so heap snapshots from node --inspect only contain your process’s objects and its dependencies. The issue is specific to profiling pages inside a user’s browser profile.
Related
- Mastering the Chrome DevTools Memory Tab — the parent topic
- Best Practices for Profiling Single-Page Apps in DevTools — the wider SPA profiling routine this setup feeds into
- Finding Detached DOM Nodes in Heap Snapshots Fast — reading detached trees once the noise is gone
- Browser DevTools & Performance Profiling Workflows — the section overview