MetaMask / MetaMask/metamask-extension
[Bug]: Extension background renderer leaks memory unboundedly — stream pipelines accumulate listeners across reconnects (multi-GB over days)
- Dominant language
- TypeScript
- Stars
- 13.2k
- Forks
- 5.6k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 451
Description
### Describe the bug
The MetaMask extension background renderer grows without bound for as long as
the browser stays open. On my system the extension renderer reached
2,649,772 kB RSS plus 320 MB swap after 13.4 days of browser uptime
(~640 bytes/second sustained), with MetaMask as the only wallet extension
installed. Restarting the browser was the only way to reclaim the memory.
Every page console also shows the Node EventEmitter leak detector firing:
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 close listeners added. Use emitter.setMaxListeners() to increase limit
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 end listeners added. Use emitter.setMaxListeners() to increase limit
(source: contentscript.js)
I have since uninstalled MetaMask because of this leak — it forced me to
periodically restart my browser to reclaim multiple gigabytes.
### Expected behavior
Memory should stay bounded regardless of browser uptime or page navigation.
Stream re-establishment (BFCache restore, service-worker restart, error
reconnect) should remove the previous graph's listeners instead of stacking
new pipelines onto persistent substreams.
### Screenshots/Recordings
_No response_
### Steps to reproduce
1. Install MetaMask (13.44.0), browse normally (dapp interaction optional).
2. Keep the browser open for days; watch the extension renderer in the
browser's task manager. Slow unbounded growth (~640 B/s in my case;
2.6 GB after 13 days).
3. Open any page console: MaxListenersExceededWarning for close/end
listeners appears at the default threshold (11).
4. Amplified variant (from a related report in the discussion of this leak):
a second wallet extension plus a hidden cross-origin iframe with
postMessage traffic raises the rate to 10–20 MB/s.
5. Restarting the browser resets the process size.
### Error messages or log output
```shell
contentscript.js:14083 MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 close listeners added. Use emitter.setMaxListeners() to increase limit
contentscript.js:14083 MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 end listeners added. Use emitter.setMaxListeners() to increase limit
```
### Where was this bug found?
Live version (from official store)
### Version
13.46.1
### Build type
None
### Browser
Chrome
### Operating system
Linux
### Hardware wallet
_No response_
### Additional context
Disclosure: This bug report, including the source analysis and the suggested
code fix below, was prepared with AI assistance (GLM 5.3). I have already
uninstalled MetaMask over this leak — it forced me to periodically restart
my browser to reclaim multiple gigabytes — and I do not intend to spend
further time on this or to keep using the extension. This report is provided
as-is for the maintainers' benefit; I will not be available for extended
back-and-forth or testing.
Static analysis of the v13.44.0 source suggests the mechanism. The content
script has three stream re-establishment paths with asymmetric teardown:
1. Error reconnect: provider-stream.ts:341-346 destroys streams and calls
setTimeout(setupExtensionStreams, 1000) on any disconnect error (see also
the TODO referencing #31893 above it).
2. BFCache restore: contentscript.js:39-44 calls setupExtensionStreams() on
pageshow(persisted). If pageshow arrives without its matching
pagehide(persisted) teardown (BFCache eviction under memory pressure),
re-setup stacks new pipeline() calls onto the persistent page-side
substreams without removeAllListeners() first.
3. Service-worker READY: provider-stream.ts:293-302.
Each pipeline(pageChannel, ...) adds end/close listeners to the persistent
substreams. The muxes have setMaxListeners(25) (provider-stream.ts:54, 122,
201, 222) but the substreams keep the default limit of 10 — which is exactly
why the warning fires at 11 listeners. The setMaxListeners(25) calls read as
a mitigation for known listener growth on the muxes; the substreams appear
to have been missed.
Related asymmetries in teardown:
- destroyExtensionStreams() (provider-stream.ts:173-186) sets
extensionStream = null without calling removeAllListeners()/destroy() on it.
- The legacy page-side channels are never destroyed.
- In background.js, the UI-port branch gets lifecycle management
(finished(), listener removal around line 1743), but the dapp/content
script port branch (around lines 1906-1938) registers no onDisconnect
teardown for the per-port stream graph created by
setupUntrustedCommunication*.
Suggested fix (code suggestion generated with GLM 5.3, not build- or
runtime-tested — placing it is up to maintainers who know the legacy-path
constraints): make setupExtensionStreams() idempotent by tearing down any
existing extension-side graph before rebuilding:
--- a/app/scripts/streams/provider-stream.ts
+++ b/app/scripts/streams/provider-stream.ts
@@ export const setupExtensionStreams = () => {
+ // Re-entry guard: tear down a stale extension-side graph so pipelines
+ // don't stack end/close listeners on the persistent page substreams
+ // (BFCache pageshow without persisted pagehide, reconnect races).
+ if (extensionStream) {
+ extensionStream.removeAllListeners();
+ extensionStream.destroy();
+ extensionStream = null;
+ }
METAMASK_EXTENSION_CONNECT_SENT = true;
and destroy the port stream in destroyExtensionStreams():
- extensionStream = null;
+ extensionStream?.removeAllListeners();
+ extensionStream?.destroy();
+ extensionStream = null;
A more complete fix would also remove listeners from pageChannel/caipChannel
on re-entry and add onDisconnect cleanup for untrusted ports in
background.js.
Related: #26337, #35241, #31893 (referenced by comments in this code), and
the related discussion report describing the two-wallet + hidden cross-origin
iframe amplifier (10-20 MB/s growth).
Measured impact on my system: extension renderer at 2,649,772 kB RSS +
320 MB swap after 13.4 days of browser uptime (~640 bytes/second sustained),
MetaMask as the only wallet extension installed.
### Severity
_No response_
Contributor guide
Research direction
Start with app/scripts/streams/provider-stream.ts, especially setupExtensionStreams() and destroyExtensionStreams(), then trace the BFCache path in contentscript.js and the dapp/content-script port path in background.js. Verify how reconnect and service-worker READY teardown behaves before changing it. Done means repeated stream re-establishment no longer stacks listeners and memory remains bounded.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100