Skip to content

Web Clipper eagerly creates a full extension iframe in every eligible tab, causing excessive memory use #2290

Description

@BlackRockCity
Image

Have you read a contributing guide?

  • I have read CONTRIBUTING.md
  • I have searched the existing issues and didn't find any that were similar
  • I have considered creating a pull request with fixes instead of a bug report and want to proceed

Current Behavior

With many tabs open, Chrome Task Manager shows the Anytype Web Clipper using approximately 4.0 GB of memory while idle, with 0% CPU and no network activity.

The extension process contains dozens of repeated entries labeled Subframe: Anytype Web Clipper, suggesting that the extension creates and keeps a live Anytype iframe in many or all eligible tabs, even when the clipper has not been opened.

The current implementation appears to inject js/foreground.js on <all_urls>, immediately create an iframe, load iframe/index.html, and keep that iframe alive while hidden. As a result, extension memory use appears to increase substantially with the number of open tabs.

Expected Behavior

The Web Clipper should remain lightweight and inactive until the user explicitly invokes it.

Opening ordinary web pages should not create or retain a full Anytype Web Clipper iframe in every eligible tab. Chrome Task Manager should not show one Subframe: Anytype Web Clipper entry per open tab while the extension is idle.

The iframe and its application resources should be created only when the user opens the clipper. Closing the clipper should release those resources, or retain only a small, documented amount of memory. Idle memory use should remain approximately constant as additional unused tabs are opened.

Steps To Reproduce

  1. Install and enable the Anytype Web Clipper extension in Google Chrome or another Chromium-based browser.

  2. Allow the extension to run on all sites.

  3. Open a large number of ordinary webpages in separate tabs. Do not open or use the Web Clipper in those tabs.

  4. Open Chrome Task Manager:

    • On macOS, select Window > Task Manager in Chrome.
    • On other platforms, use Chrome's Task Manager shortcut or menu.
  5. Locate the Anytype Web Clipper process.

  6. Expand or inspect the process entries.

  7. Observe that Chrome lists many repeated entries named Subframe: Anytype Web Clipper, approximately corresponding to the number of eligible open tabs.

  8. Observe that the extension process may consume several gigabytes of memory while showing little or no CPU or network activity.

  9. Close groups of tabs and compare the number of Anytype subframes and the extension's memory use.

  10. Optionally disable the extension and restart Chrome. Confirm that the Anytype subframes disappear and Chrome's memory use decreases.

Environment

- OS:
- Version:
- OS: macOS 26.4.1
- Hardware: Apple Silicon Mac with 16 GB RAM
- Browser: Google Chrome
- Chrome version: 149.0.7827.200 (Official Build) (arm64)
- Anytype Web Clipper version: 0.0.8
- Extension site access: On all sites
- Anytype desktop app running: Yes
- Number of open tabs at time of observation: ~40
- Observed Web Clipper memory use: approximately 4.0 GB
- Observed CPU use: 0%
- Observed network use: 0

Anything else?

Technical analysis

The runtime symptom is consistent with the extension eagerly loading its full iframe application in every eligible tab.

The current develop branch contains three relevant areas:

1. Broad content-script injection

File:

dist/extension/manifest.chromium.json

The Chromium manifest declares js/foreground.js as a content script and includes the Web Clipper iframe as a web-accessible resource.

Relevant source:

https://github.com/anyproto/anytype-ts/blob/develop/dist/extension/manifest.chromium.json

The generated manifest is currently stored as a single line, so GitHub reports the relevant content at line 1 rather than useful individual line ranges.

The important declarations are:

"content_scripts": [
  {
    "js": ["js/foreground.js"],
    "css": ["css/foreground.css"],
    "matches": ["<all_urls>"]
  }
]

and:

"web_accessible_resources": [
  {
    "resources": ["iframe/index.html"],
    "matches": ["<all_urls>"]
  }
]

This means the foreground content script can be installed broadly across eligible pages. Broad injection is not inherently problematic if the script remains lightweight, but the current foreground script immediately loads the heavier iframe application.

2. Eager iframe construction

File:

dist/extension/js/foreground.js

Relevant source:

https://github.com/anyproto/anytype-ts/blob/develop/dist/extension/js/foreground.js

This generated file is also stored on one line, so the relevant code is effectively at line 1.

During initial content-script execution, it immediately creates the container, dimmer, and iframe:

const body = document.querySelector('body');
const container = document.createElement('div');
const dimmer = document.createElement('div');
const iframe = document.createElement('iframe');

It then appends the container and loads the iframe application without waiting for the user to invoke the clipper:

if (body && !document.getElementById(iframe.id)) {
  body.appendChild(container);
}

container.id = ['anytypeWebclipper', 'container'].join('-');
container.appendChild(iframe);
container.appendChild(dimmer);

iframe.id = ['anytypeWebclipper', 'iframe'].join('-');
iframe.src = chrome.runtime.getURL('iframe/index.html');

The clickMenu handler does not construct the UI. It only reveals the iframe that was already loaded:

case 'clickMenu': {
  container.style.display = 'block';
  break;
}

Likewise, closing the clipper only hides the container:

case 'hide': {
  container.style.display = 'none';
  break;
}

and:

case 'clickClose':
  container.style.display = 'none';
  break;

Consequently, the effective lifecycle appears to be:

Page loads
  -> foreground.js executes
  -> iframe is created
  -> iframe/index.html loads
  -> iframe application remains alive while hidden
  -> clickMenu only makes it visible
  -> close only hides it again

When this occurs across many tabs, Chrome reports many entries named Subframe: Anytype Web Clipper, and the aggregate retained memory can become very large.

3. The iframe contains a substantial application

File:

extension/iframe.tsx

Relevant source:

https://github.com/anyproto/anytype-ts/blob/develop/extension/iframe.tsx

The iframe is not a lightweight placeholder. It imports and mounts application infrastructure including:

  • React
  • React Router
  • MobX
  • MobX React Provider
  • shared application stores
  • ListMenu
  • the clipper index and create views
  • extension utility and authorization code

On mount, it also initializes application services:

U.Router.init(history);
U.Smile.init();

The iframe registers a runtime message listener that can initialize and authorize the extension connection:

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  switch (msg.type) {
    case 'initIframe':
      const { appKey, gatewayPort, serverPort } = msg;
      Util.init(serverPort, gatewayPort);
      Util.authorize(appKey);
      sendResponse({});
      break;

    case 'clickMenu':
      S.Extension.setTabUrl(msg.url);
      S.Extension.setHtml(msg.html);
      U.Router.go('/create', {});
      sendResponse({});
      break;
  }

  return true;
});

Each live iframe can therefore retain its own:

  • iframe document and global execution context
  • React tree
  • Router state
  • MobX reactions and observer state
  • DOM and style state
  • event listeners
  • application-store references
  • authorization or connection-related state

Chromium may share some compiled resources internally, but each frame still has meaningful per-instance state.

Secondary defects

Ineffective duplicate-instance check

The foreground script checks for an existing element before assigning the iframe its ID:

const iframe = document.createElement('iframe');

if (body && !document.getElementById(iframe.id)) {
  body.appendChild(container);
}

iframe.id = 'anytypeWebclipper-iframe';

At the time of the check, iframe.id is an empty string. The check is effectively:

document.getElementById('')

This normally returns null, so the intended guard cannot detect an existing Web Clipper iframe.

This may not explain the normal one-frame-per-tab behavior because declarative content scripts are ordinarily injected once per document. It does make the implementation vulnerable to duplicate instances if the script is reinjected during development, extension reloads, programmatic injection, tests, or future lifecycle changes.

A valid guard should use fixed IDs before creating or appending anything:

const containerId = 'anytypeWebclipper-container';
const iframeId = 'anytypeWebclipper-iframe';

if (
  document.getElementById(containerId) ||
  document.getElementById(iframeId)
) {
  return;
}

Missing React effect cleanup

The useEffect in extension/iframe.tsx registers:

chrome.runtime.onMessage.addListener(...)
window.addEventListener('beforeunload', onBeforeUnload)

but does not return a cleanup function.

This is unlikely to be the primary cause while the iframe remains mounted for the full page lifetime, but it is unsafe if the iframe is later created and destroyed on demand. The listener callbacks should be named and removed during unmount:

useEffect(() => {
  const onRuntimeMessage = (msg, sender, sendResponse) => {
    // Existing message handling
  };

  const onBeforeUnload = () => {
    if (!S.Auth.token) {
      return;
    }

    U.Data.destroySubscriptions(() => U.Data.closeSession());
  };

  chrome.runtime.onMessage.addListener(onRuntimeMessage);
  window.addEventListener('beforeunload', onBeforeUnload);

  return () => {
    chrome.runtime.onMessage.removeListener(onRuntimeMessage);
    window.removeEventListener('beforeunload', onBeforeUnload);
  };
}, []);

Confidence and limitations

The source inspection directly demonstrates that:

  • the content script is broadly injected,
  • the foreground script creates the iframe during initialization,
  • the iframe loads iframe/index.html immediately,
  • opening the clipper merely reveals the existing frame,
  • closing it merely hides the frame,
  • the iframe mounts a nontrivial React, Router, and MobX application,
  • the duplicate guard uses the iframe ID before assigning it,
  • the iframe effect lacks explicit listener cleanup.

The screenshot directly demonstrates that:

  • Chrome reports many Anytype Web Clipper subframes,
  • the extension process was using approximately 4 GB,
  • the process was idle or nearly idle at the time.

The evidence does not yet prove:

  • the exact memory cost of each individual iframe,
  • perfectly linear memory growth per tab,
  • whether there is a separate unbounded memory leak,
  • whether every frame completes authorization or establishes subscriptions,
  • how Chrome accounts for shared versus private memory in the displayed 4 GB total.

I would therefore describe the primary defect as excessive idle memory caused by eager per-tab iframe application instantiation, rather than definitively calling it an unbounded memory leak.

Minimal fix

The lowest-risk correction is to retain the lightweight content script but defer iframe creation until clickMenu.

Recommended changes:

  1. Move container, dimmer, and iframe creation into an idempotent ensureClipper() function.
  2. Call ensureClipper() only when the user invokes the clipper.
  3. Assign and check fixed IDs before appending elements.
  4. Preserve the existing message protocol where possible.
  5. Add a readiness handshake so initialization messages are not sent before the new iframe has loaded.
  6. Add cleanup for the listeners registered by extension/iframe.tsx.
  7. Initially keep the iframe alive after first use if teardown introduces too much risk, but do not create it in untouched tabs.

Illustrative structure:

const containerId = 'anytypeWebclipper-container';
const iframeId = 'anytypeWebclipper-iframe';

let container = null;
let iframe = null;
let dimmer = null;

function ensureClipper() {
  const existingContainer = document.getElementById(containerId);
  const existingIframe = document.getElementById(iframeId);

  if (existingContainer && existingIframe) {
    container = existingContainer;
    iframe = existingIframe;
    return container;
  }

  if (!document.body) {
    return null;
  }

  container = document.createElement('div');
  dimmer = document.createElement('div');
  iframe = document.createElement('iframe');

  container.id = containerId;
  iframe.id = iframeId;
  iframe.src = chrome.runtime.getURL('iframe/index.html');
  dimmer.className = 'dimmer';

  dimmer.addEventListener('click', hideClipper);

  container.appendChild(iframe);
  container.appendChild(dimmer);
  document.body.appendChild(container);

  return container;
}

function showClipper() {
  const element = ensureClipper();

  if (element) {
    element.style.display = 'block';
  }
}

function hideClipper() {
  if (container) {
    container.style.display = 'none';
  }
}

This should eliminate full Web Clipper subframes from tabs where the user never invokes the extension, while minimizing changes to the existing architecture.

More complete fix

A stronger lifecycle fix would additionally destroy the iframe when the user closes the clipper:

function destroyClipper() {
  if (container) {
    container.remove();
  }

  container = null;
  iframe = null;
  dimmer = null;
}

Before removing the frame, the code should explicitly dispose of:

  • runtime message listeners,
  • window event listeners,
  • MobX reactions,
  • subscriptions,
  • sessions owned by that iframe,
  • the React root, if required by the current rendering setup.

A ready and shutdown handshake would reduce race conditions:

User invokes clipper
  -> foreground creates iframe
  -> iframe sends iframeReady
  -> background sends initIframe
  -> iframe acknowledges initialization
  -> background sends clipping data
  -> user closes clipper
  -> foreground sends dispose request
  -> iframe closes subscriptions and session
  -> iframe acknowledges disposal
  -> foreground removes container

This maximizes memory recovery but carries more regression risk than simple lazy creation.

Maximum architectural fix

The cleanest design would remove the persistent <all_urls> content script where feasible and inject a minimal script only after an explicit user gesture using chrome.scripting.executeScript() and activeTab.

The manifest already requests both scripting and activeTab.

Possible flow:

User clicks toolbar action or context menu
  -> service worker identifies the active tab
  -> service worker injects a minimal content script
  -> content script extracts the required page or selection data
  -> content script creates the iframe
  -> iframe performs the clipping workflow
  -> close disposes and removes the iframe

Advantages:

  • no Web Clipper code in untouched tabs,
  • no idle iframe instances,
  • reduced memory and DOM footprint,
  • narrower page access,
  • clearer user-action-driven lifecycle.

Areas requiring careful validation:

  • toolbar invocation
  • context-menu invocation
  • selected-content clipping
  • full-page clipping
  • pages where script injection is prohibited
  • browser internal pages
  • iframe readiness and message ordering
  • Firefox compatibility
  • service-worker suspension
  • single-page application navigation

Recommended tests

Functional tests

Verify all existing clipping workflows:

  • toolbar button
  • context menu
  • full-page clipping
  • selection clipping
  • authenticated state
  • unauthenticated state
  • repeated opening in the same tab
  • opening in several tabs
  • closing before initialization completes
  • closing and reopening repeatedly

DOM lifecycle tests

Before invocation:

expect(
  document.getElementById('anytypeWebclipper-iframe')
).toBeNull();

After invocation:

expect(
  document.querySelectorAll('#anytypeWebclipper-iframe')
).toHaveLength(1);

After repeated invocation:

expect(
  document.querySelectorAll('#anytypeWebclipper-iframe')
).toHaveLength(1);

After teardown, if teardown is implemented:

expect(
  document.getElementById('anytypeWebclipper-iframe')
).toBeNull();

Performance test

Using a clean Chrome profile and a fixed extension build:

  1. Record extension memory and subframe count with 1, 10, 25, 50, and 100 tabs.
  2. Do not invoke the clipper.
  3. Invoke the clipper in one tab only.
  4. Close it.
  5. Close all but one tab.
  6. Repeat the measurement after garbage collection or a suitable settling period.

Expected result before invocation:

Tabs                       1    10    25    50    100
Web Clipper iframe count   0     0     0     0      0

Expected result after invoking the clipper in one tab:

Web Clipper iframe count   1

Idle extension memory should remain approximately flat as untouched tabs are added, apart from any intentionally retained lightweight content-script overhead.

Leak test

After lazy creation is implemented:

  1. Open and close the clipper 20 to 100 times in one tab.
  2. Capture heap snapshots at regular intervals.
  3. Force garbage collection in a development environment where possible.
  4. Check retained objects for:
    • detached iframe DOM trees,
    • React roots,
    • MobX reactions,
    • Router histories,
    • runtime listeners,
    • window listeners,
    • subscription callbacks,
    • session objects.

This will determine whether a second, conventional lifecycle leak exists after the primary eager-loading problem is removed.

Suggested acceptance criteria

  • An untouched tab contains no anytypeWebclipper-iframe.
  • Chrome Task Manager does not show one Anytype subframe for every unused tab.
  • The iframe is created only following explicit user invocation.
  • Repeated invocation creates no duplicate iframe.
  • Existing clipping workflows continue to operate.
  • Registered listeners are removed when their owning iframe is destroyed.
  • Closing tabs or destroying the clipper releases the associated resources within a reasonable settling period.
  • Idle memory does not materially increase as unused tabs are opened.
  • Chromium and Firefox builds both pass their relevant tests.

Prompt for Codex or Claude Code

Investigate and fix excessive idle memory use in the Anytype Web Clipper in the anyproto/anytype-ts repository.

Observed behavior:
Chrome Task Manager shows many entries named "Subframe: Anytype Web Clipper", approximately one for each eligible open tab, and the aggregate extension process can reach about 4 GB while idle.

Relevant files:
- dist/extension/manifest.chromium.json
- dist/extension/js/foreground.js
- extension/iframe.tsx
- the source or build inputs that generate the files under dist/extension, if available

Current behavior to verify:
1. The Chromium manifest broadly injects js/foreground.js.
2. foreground.js immediately creates a container, dimmer, and iframe.
3. iframe.src is immediately assigned chrome.runtime.getURL('iframe/index.html').
4. clickMenu merely changes container.style.display to block.
5. hide and clickClose merely change display to none.
6. The duplicate guard checks document.getElementById(iframe.id) before iframe.id is assigned.
7. extension/iframe.tsx registers chrome.runtime.onMessage and beforeunload listeners without an explicit React effect cleanup function.

Primary goal:
Do not load the full iframe application in tabs where the user has not invoked the clipper.

Implement the lowest-risk production-quality fix:
- Keep any required lightweight content script.
- Move iframe construction into an idempotent ensureClipper() function.
- Create the iframe only after clickMenu or the equivalent explicit invocation.
- Use fixed container and iframe IDs and prevent duplicate instances.
- Preserve full-page and selection clipping behavior.
- Add a reliable iframe-ready handshake if messages can otherwise arrive before iframe initialization.
- Add cleanup for runtime and window listeners in extension/iframe.tsx.
- Modify source files rather than only generated dist artifacts where a source generator exists.
- Regenerate the Chromium and Firefox extension outputs as required.

Consider, but do not implement unless it is safe and well-tested:
- destroying the iframe on close to recover memory,
- replacing the persistent content script with on-demand chrome.scripting.executeScript injection.

Add or update tests for:
- no iframe before invocation,
- one iframe after invocation,
- no duplicate iframe after repeated invocation,
- correct hide or destroy behavior,
- listener cleanup,
- toolbar invocation,
- context-menu invocation,
- selection clipping,
- full-page clipping,
- authenticated and unauthenticated states.

Also provide:
1. A concise explanation of the root cause.
2. A list of changed files.
3. Any message-ordering or browser-compatibility risks.
4. Build and test commands used.
5. A manual Chrome memory-test procedure comparing 1, 10, 25, 50, and 100 untouched tabs.
6. Before-and-after Web Clipper iframe counts.
7. Any remaining uncertainty about a secondary memory leak.

Do not claim that the 4 GB observation proves an unbounded memory leak. Treat eager per-tab iframe application instantiation as the confirmed architectural problem, then profile for any additional leak separately.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions