Skip to content

[Bug]: Page.captureScreenshot never returns in Chromium when the page navigates away right after it is sent #43685

Open

Description

Version

1.64.1, also main (1.65.0-next)

Steps to reproduce

Run the script below with node repro.js. Set CHANNEL=chromium or CHANNEL=chrome to use the full browser instead of the headless shell.

// Page.captureScreenshot never returns when the page navigates away right after it is sent.
// Chromium bug: https://issues.chromium.org/issues/578475983
//
//   npm i playwright && npx playwright install chromium
//   node repro.js
const http = require('http');
const { chromium } = require('playwright');

const pages = {
  // a.html navigates to b.html as soon as it runs; b.html stays.
  '/a.html': `<script>setTimeout(() => location.href = 'b.html', 1)</script>`,
  '/b.html': `<div>b</div>`,
};
const server = http.createServer((req, res) => {
  res.writeHead(pages[req.url] ? 200 : 404, { 'Content-Type': 'text/html' });
  res.end(pages[req.url] || '');
});

server.listen(0, async () => {
  const prefix = `http://localhost:${server.address().port}`;
  const browser = await chromium.launch({ channel: process.env.CHANNEL });
  let lost = 0;
  for (let i = 0; i < 10; i++) {
    const page = await browser.newPage();
    const session = await page.context().newCDPSession(page);
    await session.send('Page.enable');
    const pending = new Set();
    // Capture at each commit of the main frame, as page.screenshot() does when the call lands there.
    session.on('Page.frameNavigated', ({ frame }) => {
      if (frame.parentId)
        return;
      const path = new URL(frame.url).pathname;
      pending.add(path);
      session.send('Page.captureScreenshot', { format: 'png' }).then(() => pending.delete(path), () => pending.delete(path));
    });
    await page.goto(prefix + '/a.html');
    await page.waitForURL('**/b.html');
    await page.waitForTimeout(5000);
    for (const path of pending) {
      lost++;
      console.log(`iteration ${i}: Page.captureScreenshot sent at the commit of ${path} got no response in 5 s`);
    }
    await page.close();
  }
  console.log(`${browser.version()}: ${lost} of 10 iterations lost a Page.captureScreenshot response`);
  await browser.close();
  server.close();
});

Expected behavior

Each Page.captureScreenshot returns the image, or an error such as Not attached to an active page when the document goes away.

Actual behavior

The Page.captureScreenshot that is sent at the commit of a.html never returns, while a.html navigates to b.html:

iteration 5: Page.captureScreenshot sent at the commit of /a.html got no response in 5 s
iteration 6: Page.captureScreenshot sent at the commit of /a.html got no response in 5 s
156.0.8078.4: 5 of 10 iterations lost a Page.captureScreenshot response
Browser Iterations with no response
Headless shell 156.0.8078.4 5 of 10
Chromium 156.0.8078.4, channel: 'chromium' 10 of 10
Chrome 156.0.8078.47, channel: 'chrome' 10 of 10

When the call lands at that moment, page.screenshot() waits until its timeout.

Additional context

Chromium bug: https://issues.chromium.org/issues/578475983. It has a standalone repro with no Playwright code.

How we found it. The Java port of SS-027 should work while navigating (tests/page/page-screenshot.spec.ts) times out in Chromium on CI, and in about 1 of 6 local runs on 2 cores. The call log ends at fonts loaded. The pw:protocol log shows:

  • Page.captureScreenshot was sent right after a document of the redirect loop committed, and the next navigation started.
  • The loop ended and the page became idle, but the call got no response for 30 seconds.
  • Runtime.evaluate on the same session still got responses.

The back/forward cache. The response goes missing only when the old document is destroyed instead of going into the back/forward cache:

  • Playwright passes --disable-back-forward-cache, and this switch alone is enough to reproduce.
  • Without it, an unload handler on the old page has the same effect.
  • When the old page goes into the cache, Chrome answers with Not attached to an active page.

Why upstream CI does not see it. SS-027 has no Chromium failure in the CI results database: 8689 results from 1150 runs since August 14. Before it sends Page.captureScreenshot, page.screenshot() waits for fonts and reads the layout metrics, so the call usually lands later than the commit. The Java client's timing hits the window more often. The Java port disables SS-027 in Chromium for now (microsoft/playwright-java#2024).

Possibly related:

Environment

  • OS: Ubuntu 24.04, x64
  • Node.js: 26.5.0
  • Playwright: 1.64.1
  • Browsers: Chromium 156.0.8078.4 (headless shell and full), Google Chrome 156.0.8078.47

Activity

  1. jackeddisciple commented on Oct 28, 2026

    @jackeddisciple

    I’d be happy to look into this and submit a fix if nobody’s working on it already.

  2. github-actions commented on Oct 29, 2026

    @github-actions
  3. github-actions commented on Oct 29, 2026

    @github-actions
    Contributor

    Hi, I'm the Playwright bot. I reproduced this on the latest checkout.

    This is a Chromium bug, still present in 157.0.8103.15. Screenshot commands remain unanswered after navigation even when sent directly to Chromium without any Playwright code. Other commands on the same session still return. The browser report below adds evidence for the existing https://issues.chromium.org/issues/578475983.

    Reproduction and results

    Tested on Ubuntu 24.04.5 x64, Node.js 24.22.1, Playwright 1.65.0-next, commit bda54b052be6c5272557f3f1c972ad378bd1dc60.

    The fixture-based test serves /a.html with:

    <script>setTimeout(() => location.href = "/b.html", 1)</script>

    /b.html is a static page. After Page.enable, the test sends Page.captureScreenshot at every main-frame Page.frameNavigated event, navigates to /a.html ten times, and checks that every command receives either an image or an error. It waits 500 ms after each arrival at /b.html, then another five seconds for outstanding responses. Runtime.evaluate('location.pathname') still returns /b.html.

    Run Result
    Bundled Chromium headless shell 157.0.8103.15 All ten captures started at /a.html were still unanswered
    Full Chromium 157.0.8103.15, headless All ten were still unanswered
    Full Chromium 157.0.8103.15, headed under Xvfb All ten were still unanswered
    Direct browser pipe, no Playwright, --disable-back-forward-cache Two captures were still unanswered
    Direct browser pipe, same script with that flag omitted One capture was still unanswered; flag omission alone was not a reliable workaround in this run
    Existing SS-027 and SS-039, Chromium, Firefox, WebKit All six passed

    The direct-pipe reproduction uses only Node.js built-in modules and the browser executable. It is included in the browser report.

    The first attempt to run existing coverage overlapped another test process and hit a test-server port conflict. The full six-test command was then rerun without overlap and passed.

    Cause and related work

    At Chromium revision 1aa07b638799e59656559ae6c6649d4b197f555e, PageHandler::CaptureScreenshot moves its response callback into a widget snapshot request. RenderWidgetHostImpl stores the pending callback and requests a redraw. Its normal widget destruction path does not fail pending snapshots, although its renderer-exit path does. The fix belongs in Chromium's pending-snapshot completion during navigation, not in the Java client.

    #43721 was closed without a merge; a maintainer questioned retrying as the right fix. No code from that PR was run. #9757 concerns a main resource that never finishes; this reproduction finishes both resources and leaves the destination page responsive.

    The Chromium issue pages require sign-in on this runner, so I could not verify the status of the existing upstream report.

    Chromium bug report, for a maintainer to file at https://crbug.com/new:

    Title: Chromium: Page.captureScreenshot loses its response when the captured document navigates away

    Body
    # Chromium: Page.captureScreenshot loses its response when the captured document navigates away
    
    Additional evidence for the existing Chromium report https://issues.chromium.org/issues/578475983; please use that report rather than file a duplicate.
    
    ## Expected and actual
    
    Every `Page.captureScreenshot` request should receive an image or a protocol error if its document goes away. Instead, some requests receive neither, even after the next page finishes loading and becomes idle. `Runtime.evaluate` on the same session still works.
    
    This occurs without Playwright: the script below starts Chromium directly and exchanges null-delimited CDP messages over `--remote-debugging-pipe`, using only Node.js built-in modules.
    
    ## Environment and results
    
    - Ubuntu 24.04.5 LTS, x64; Node.js 24.22.1.
    - Chrome for Testing 157.0.8103.15, Chromium revision `1aa07b638799e59656559ae6c6649d4b197f555e`.
    - Ten navigations in the direct-pipe reproduction, with `--disable-back-forward-cache`: two captures remained unanswered after the final five-second wait. Some other captures correctly returned `Not attached to an active page`.
    - With the disabling flag omitted: one capture remained unanswered. This does not establish that the affected document entered the back/forward cache.
    - A separate fixture-based reproduction at Playwright commit `bda54b052be6c5272557f3f1c972ad378bd1dc60` lost all ten `/a.html` capture responses in each of: headless shell, full Chromium headless, full Chromium headed under Xvfb. It sent captures at navigation commits, bypassing Playwright's screenshot preparation.
    - The ordinary Playwright navigation screenshot tests, `SS-027` and `SS-039`, passed on Chromium, Firefox and WebKit. Their preparation usually avoids this timing window.
    
    ## Source evidence and fix location
    
    This is upstream Chromium behavior, not a Playwright browser patch.
    
    In the tested Chromium revision:
    
    - [`PageHandler::CaptureScreenshot`](https://chromium.googlesource.com/chromium/src/+/1aa07b638799e59656559ae6c6649d4b197f555e/content/browser/devtools/protocol/page_handler.cc#1424) transfers its response callback into a pending screenshot request, then passes that request to `RenderWidgetHostImpl::GetSnapshotFromBrowser`.
    - [`GetSnapshotFromBrowser`](https://chromium.googlesource.com/chromium/src/+/1aa07b638799e59656559ae6c6649d4b197f555e/content/browser/renderer_host/render_widget_host_impl.cc#2370) stores the callback in `pending_surface_browser_snapshots_`. The redraw path calls `ForceRedraw` and waits for its response before copying the surface.
    - [`Destroy`](https://chromium.googlesource.com/chromium/src/+/1aa07b638799e59656559ae6c6649d4b197f555e/content/browser/renderer_host/render_widget_host_impl.cc#2699) destroys the view but does not complete or fail pending snapshot callbacks. Destruction of a callback does not send its protocol response.
    - In contrast, the renderer-exit path explicitly calls [`FailPendingSnapshots`](https://chromium.googlesource.com/chromium/src/+/1aa07b638799e59656559ae6c6649d4b197f555e/content/browser/renderer_host/render_widget_host_impl.cc#4105). Passing an empty image to `PageHandler::ScreenshotCaptured` produces `Unable to capture screenshot`.
    
    Pending snapshot requests need a completion or failure path when navigation destroys their widget, including when a redraw response will never arrive. Retrying in Playwright does not repair the missing browser response and leaves old captures outstanding. No Chromium source change was built or tested in this investigation.
    
    ## Standalone reproduction
    
    Save as `capture-navigation.cjs`. No npm packages are required. Run:
    
    ```sh
    node capture-navigation.cjs /absolute/path/to/chrome
    ```
    
    Run several times if needed; this is a timing race. `pendingCaptures` should be empty, but contains request IDs and URLs with no response. The final `runtimeResult` remains `/b.html`. Adding `--enable-bfcache` to the script arguments only omits `--disable-back-forward-cache`; it does not force any document into the cache.
    
    ```js
    const { spawn } = require('node:child_process');
    const { mkdtempSync, rmSync } = require('node:fs');
    const { tmpdir } = require('node:os');
    const { join } = require('node:path');
    const http = require('node:http');
    const { setTimeout: delay } = require('node:timers/promises');
    
    async function main() {
      const server = http.createServer((req, res) => {
        res.setHeader('Content-Type', 'text/html');
        res.end(req.url === '/a.html'
          ? '<script>setTimeout(() => location.href = "/b.html", 1)</script>'
          : '<div>b</div>');
      });
      await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
      const prefix = `http://127.0.0.1:${server.address().port}`;
      const profile = mkdtempSync(join(tmpdir(), 'capture-navigation-'));
      const browser = spawn(process.argv[2], [
        '--headless', '--no-sandbox', '--disable-gpu',
        '--disable-background-networking', '--remote-debugging-pipe',
        ...(process.argv.includes('--enable-bfcache') ? [] : ['--disable-back-forward-cache']),
        `--user-data-dir=${profile}`, 'about:blank',
      ], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
      const exit = new Promise(resolve => browser.once('exit', resolve));
      const pending = new Map();
      const captures = new Map();
      const errors = [];
      let nextId = 0;
      let input = '';
      let sessionId;
      let reachedB;
      function send(method, params = {}, session) {
        const id = ++nextId;
        const promise = new Promise((resolve, reject) => pending.set(id, { method, resolve, reject }));
        browser.stdio[3].write(JSON.stringify({ id, method, params, sessionId: session }) + '\0');
        return { id, promise };
      }
      browser.stdio[4].on('data', data => {
        input += data.toString();
        let end;
        while ((end = input.indexOf('\0')) !== -1) {
          const message = JSON.parse(input.slice(0, end));
          input = input.slice(end + 1);
          if (message.id) {
            const request = pending.get(message.id);
            pending.delete(message.id);
            if (message.error)
              request.reject(new Error(JSON.stringify(message.error)));
            else
              request.resolve(message.result);
          } else if (message.method === 'Page.frameNavigated' && !message.params.frame.parentId) {
            const url = message.params.frame.url;
            const capture = send('Page.captureScreenshot', { format: 'png' }, sessionId);
            captures.set(capture.id, url);
            capture.promise.then(() => captures.delete(capture.id), error => {
              captures.delete(capture.id);
              errors.push({ url, error: error.message });
            });
            if (url.endsWith('/b.html'))
              reachedB();
          }
        }
      });
      try {
        const version = await send('Browser.getVersion').promise;
        console.log(JSON.stringify({ version, bfcacheEnabled: process.argv.includes('--enable-bfcache') }));
        const { targetId } = await send('Target.createTarget', { url: 'about:blank' }).promise;
        ({ sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }).promise);
        await send('Page.enable', {}, sessionId).promise;
        for (let i = 0; i < 10; ++i) {
          const navigated = new Promise(resolve => reachedB = resolve);
          await send('Page.navigate', { url: prefix + '/a.html' }, sessionId).promise;
          await navigated;
          await delay(500);
        }
        await delay(5000);
        const evaluation = await send('Runtime.evaluate', { expression: 'location.pathname' }, sessionId).promise;
        console.log(JSON.stringify({
          pendingCaptures: [...captures], errors, runtimeResult: evaluation.result.value,
        }, null, 2));
      } finally {
        browser.kill();
        await exit;
        await new Promise(resolve => server.close(resolve));
        rmSync(profile, { recursive: true, force: true });
      }
    }
    
    main().catch(error => {
      console.error(error);
      process.exitCode = 1;
    });
    setTimeout(() => {
      console.error('Reproduction exceeded 60 seconds');
      process.exit(1);
    }, 60000).unref();
    ```

    Triaged on Linux by the Playwright bot - agent run

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