Skip to content

[MCP] snapshot-mode=none still serializes the ARIA snapshot #41717

Closed

Description

@dexhunter

Description

When MCP is configured with snapshot-mode=none, response construction still calls Tab.captureSnapshot(), which always runs page.ariaSnapshot(). The serialized snapshot is then discarded because the response does not include a Snapshot section.

On a large deterministic page, the current snapshotless response path measured about 166 ms. A narrow prototype that skips only the unused ARIA serialization measured about 4.6 ms, while preserving page headers, modal state, console links, download events, and output-budget cleanup.

The relevant path is:

  • packages/playwright-core/src/tools/backend/response.ts
  • packages/playwright-core/src/tools/backend/tab.ts

Expected behavior

With snapshot-mode=none, MCP should avoid generating an ARIA snapshot that will not be returned. It should continue to collect and emit all non-snapshot response state, including:

  • changed tab/page headers
  • modal state
  • console links
  • download events
  • output-budget housekeeping

Proposed change

Pass whether an ARIA snapshot is needed into Tab.captureSnapshot() and conditionally skip only the ariaSnapshot() call inside the existing modal-state race. Keep the rest of the response-building path unchanged.

I would like to work on this and can add focused coverage to the existing MCP snapshot-mode, console, dialogs, files, tabs, and output-size tests.

Activity

  1. github-actions commented on Jul 9, 2026

    @github-actions
    Contributor

    Hi, I'm the Playwright bot and I took a first look at this.

    Confirmed by reading the source at tip-of-tree (npm 1.62.0-next, sha 9fb3602). With --snapshot-mode=none the response path still serializes an ARIA snapshot it never uses. Response._build() calls currentTabOrDie().captureSnapshot(...) unconditionally, and Tab.captureSnapshot() always runs page.ariaSnapshot({ mode: 'ai' }) inside the modal-state race. The resulting ariaSnapshot string is then discarded, because the Snapshot section is only emitted when _includeSnapshot !== 'none'. So the serialization is pure wasted work in this mode — the analysis in the report is accurate.

    The exact path

    packages/playwright-core/src/tools/backend/response.ts — _build():

    const tabSnapshot = this._context.currentTab()
      ? await this._context.currentTabOrDie().captureSnapshot(...)  // always called
      : undefined;
    ...
    if (tabSnapshot && this._includeSnapshot !== 'none') {   // snapshot only USED here
      ...
      addSection('Snapshot', ...);
    }

    packages/playwright-core/src/tools/backend/tab.ts — captureSnapshot():

    const modalStates = await this._raceAgainstModalStates(async () => {
      const ariaSnapshot = root
        ? await root.ariaSnapshot({ mode: 'ai', depth, boxes })
        : await this.page.ariaSnapshot({ mode: 'ai', depth, boxes });  // runs even for snapshot-mode=none
      ...
    });

    The snapshot object is still needed here for modalStates, consoleLink, and events, so captureSnapshot() can't simply be skipped — only the ariaSnapshot() call inside it is dead in this mode. Threading a "snapshot needed" flag into captureSnapshot() and skipping just that call, as proposed, looks like the right shape and preserves the modal-state race, headers, console links, download events, and output-budget cleanup.

    This is a valid, well-scoped optimization. Existing coverage lives in tests/mcp/snapshot-mode.spec.ts (should respect --snapshot-mode=none), which is a good anchor for a regression test asserting the non-snapshot state is still emitted while no ARIA snapshot is produced.

    I'm a first pass here, not the final word — I confirmed the wasted-work path by code inspection rather than benchmarking, so I can't vouch for the specific 166 ms → 4.6 ms numbers, but the redundant serialization is real.

    Triage run

  2. yury-s commented on Jul 9, 2026

    @yury-s
    Member

    What is the practical use case where you need --snapshot-mode=none and this optimization would make any meaningful difference? In general, this mode is an edge case which we would optimize for.

  3. luoshixin93-sudo commented on Jul 10, 2026

    @luoshixin93-sudo
  4. dexhunter commented on Jul 11, 2026

    @dexhunter
    Author

    One clarification on the practical use case: this is not limited to sessions launched with --snapshot-mode=none. My repro uses --snapshot-mode=full, which is the default. Tools such as browser_tabs with action: "list" and browser_console_messages do not request a response snapshot, so _includeSnapshot remains none; today they still serialize and discard the full ARIA tree.

    A normal agent loop may poll tab state or console output between actions and take an explicit snapshot only when it needs page semantics. On current main d448aac01, the median for one tab-list call plus one filtered console call was 172.763, 170.164, and 166.731 ms across three fresh sessions. Applying the small race-preserving change from #41719 reduced those medians to 4.663, 4.600, and 4.686 ms, about 97.26% by the median of medians.

    The strict evaluator retained all 2,400 actionable nodes in automatic and explicit snapshots, checked that snapshots update after a DOM mutation, preserved console and modal state plus download events and files, and kept screenshot and error behavior unchanged. The focused Chromium MCP selection passed all 56 tests, along with build, TypeScript, ESLint, and diff checks.

    I am happy to add or adjust focused tests, but I will wait for assignment or explicit community-contribution approval before opening a PR.

  5. dgozman commented on Jul 14, 2026

    @dgozman
    Collaborator

    Dex Hünter (@dexhunter) Could you please share the page where you experience this issue? From our experience, 172ms is nothing when working with agents, because they spend time generating the tokens anyway. That said, 172ms is quite a lot for taking a snapshot, so we are interested in improving our snapshots, and would love to see a page where they are so slow.

  6. dexhunter commented on Jul 16, 2026

    @dexhunter
    Author

    Yes. I used a synthetic page so the reproduction is deterministic and shareable. It approximates an admin/workflow page with a large accessibility tree: 2,400 buttons, one input, one download link, 120 console messages, and one alert handler.

    import http from 'node:http';
    
    const actions = Array.from({ length: 2400 }, (_, index) => {
      const id = String(index).padStart(4, '0');
      const handler = index === 0
        ? ` onclick="setTimeout(() => alert('fixture-dialog'), 50)"`
        : '';
      return `<li><button data-index="${id}" aria-label="Action ${id}"${handler}>Action ${id}</button></li>`;
    }).join('');
    
    const logs = Array.from(
      { length: 120 },
      (_, index) => `console.info('fixture-log-${String(index).padStart(3, '0')}');`,
    ).join('\n');
    
    const html = `<!doctype html>
    <html lang="en">
    <meta charset="utf-8">
    <title>Response shaping fixture</title>
    <body>
    <main>
      <h1>Response shaping fixture</h1>
      <label for="query">Search actions</label>
      <input id="query" value="semantic-marker">
      <a href="/download" download="fixture-download.txt">Download fixture</a>
      <ul>${actions}</ul>
    </main>
    <script>${logs}</script>`;
    
    http.createServer((req, res) => {
      if (req.url === '/download') {
        res.writeHead(200, {
          'content-disposition': 'attachment; filename=fixture-download.txt',
          'content-type': 'text/plain',
        });
        res.end('fixture-download-data');
        return;
      }
      res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
      res.end(html);
    }).listen(0, '127.0.0.1', function() {
      console.log(`http://127.0.0.1:${this.address().port}/`);
    });

    I start MCP with the default --snapshot-mode=full, navigate to the printed URL, then time one browser_tabs({ action: "list" }) call plus one browser_console_messages({ level: "error" }) call. Neither response includes a ### Snapshot section, but current main still builds the full ARIA snapshot internally.

    I reran this as two reversed-order fresh pairs, with two warmups and ten measured calls per session. On current main 55679f5f3d4b4f3e2534ec0ce2fc5683ba2eaf3f, the combined baseline median was 342.144 ms. Preserving the existing modal race, console-link handling, event transfer, and cleanup while replacing only the unused ARIA payload with an empty string measured 16.104 ms (95.3% lower).

    Environment: Node v24.13.1, macOS arm64, Google Chrome for Testing 151.0.7922.10.

  7. dgozman commented on Jul 17, 2026

    @dgozman
    Collaborator

    Dex Hünter (@dexhunter) Is this representative of your actual page? If we optimize aria snapshot for this particular case, but it turns out your actual page has some very different structure, things might still be very slow for you.

  8. dgozman commented on Jul 27, 2026

    @dgozman
    Collaborator

    Closing as per above.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions