Skip to content

[Regression 1.62.1 → 1.63.0]: page Response.body() returns empty after in-page fetch().blob() already consumed it #42862

Closed

Description

Context

Bisected while investigating a CI break from a routine @playwright/test 1.62.1 → 1.63.0 bump in apache/superset (Dependabot apache/superset#44231). Several e2e tests that assert on a downloaded file's contents via page.waitForResponse(...) + response.body() started failing with a 0-byte body, while the app itself (and the in-page blob.size) was completely fine.

Bug

A page-level Response.body() (captured via page.waitForResponse()) returns an empty buffer if the in-page JavaScript has already consumed the same fetch() Response's body (e.g. via .blob(), as any real download-triggering code does: const blob = await response.blob()).

  • Without in-page consumption, response.body() works correctly on both 1.62.1 and 1.63.0.
  • With in-page consumption via .blob(), response.body() returns 0 bytes on 1.63.0, while the browser itself received the full body fine (blob.size is correct) - so this looks like a regression in how Playwright's own CDP-based body capture interacts with a body stream the page has also read, not a real network/browser issue.

Repro

Self-contained, only needs @playwright/test installed for chromium:

// standalone-repro.mjs
import http from 'node:http';
import { chromium } from '@playwright/test';

const body = Buffer.alloc(50000, 0x50);

const server = http.createServer((req, res) => {
  if (req.url === '/blank') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(`<html><body>
      <button id="go">go</button>
      <script>
        document.getElementById('go').addEventListener('click', async () => {
          const response = await fetch('/export');
          // Consuming the body in-page (as any real app does with a
          // downloaded file) is what triggers the regression.
          const blob = await response.blob();
          window.__blobSize = blob.size;
        });
      </script>
    </body></html>`);
    return;
  }
  res.writeHead(200, {
    'Content-Type': 'application/octet-stream',
    'Content-Length': String(body.length),
  });
  res.end(body);
});

await new Promise(resolve => server.listen(8899, resolve));

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:8899/blank');

const responsePromise = page.waitForResponse(res => res.url().includes('/export'));
await page.click('#go');
const response = await responsePromise;
const nodeBody = await response.body();
const inPageBlobSize = await page.evaluate(() => window.__blobSize);

console.log('expected byte length:', body.length);
console.log('in-page blob.size (browser correctly received it):', inPageBlobSize);
console.log('response.body() on the Node side:', nodeBody.length);

await browser.close();
server.close();

if (nodeBody.length !== body.length) {
  console.error('BUG: response.body() did not match the actual response body.');
  process.exit(1);
}
console.log('OK');

Results

@playwright/test@1.62.1:

expected byte length: 50000
in-page blob.size (browser correctly received it): 50000
response.body() on the Node side: 50000
OK

@playwright/test@1.63.0:

expected byte length: 50000
in-page blob.size (browser correctly received it): 50000
response.body() on the Node side: 0
BUG: response.body() did not match the actual response body.

Bisected with npm install @playwright/test@1.62.1 vs @1.63.0 in two isolated directories, otherwise identical repro and chromium build for that version. Reproduces consistently (not flaky) on both macOS 27.0 (arm64) and Ubuntu 24.04 (the CI environment where it originally surfaced).

Environment

  • Playwright Version: 1.63.0 (regression from 1.62.1, which is unaffected)
  • Browser: Chromium (chromium.launch(), default channel)
  • OS: macOS 27.0 arm64, and Ubuntu 24.04 (GitHub Actions ubuntu-24.04)
  • Node.js: v24.17.0

Impact

Any test that clicks a real download/export action and then asserts on the fetched file's contents via page.waitForResponse() + response.body() will silently see an empty body once the app's own code has read the response (which is the normal way to turn a fetch() response into a downloadable file). The actual application behavior is unaffected - this only breaks the test's ability to observe what the browser already correctly received.

Activity

  1. github-actions commented on Sep 22, 2026

    @github-actions
    Contributor

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

    Reproduced — your bisect is right. 1.62.1 returns the full 50000 bytes, tip-of-tree (1.64.0-next, sha 0a2a33c487b260359a9f82ffe661c551a168bf62) returns 0. Chromium only — Firefox and WebKit both return the full body.

    The trigger isn't really "the page consumed the body" so much as the resource type. When Network.getResponseBody comes back empty (body evicted from the CDP cache), Chromium falls back to re-fetching the URL. #42124 restricted that fallback to replay-safe requests, and #42201 narrowed it further to a resource-type allowlist:

    const kRefetchSafeResourceTypes = new Set<network.ResourceType>([
      'font', 'image', 'manifest', 'media', 'script', 'stylesheet', 'texttrack',
    ]);

    A fetch() isn't on that list, so the fallback bails and returns an empty buffer instead of failing loudly. Both PRs landed between v1.62.1 (Jul 30) and v1.63.0 (Sep 4), which matches your bisect exactly. Avoiding a side-effecting re-fetch is deliberate, but silently handing back a 0-byte body looks wrong — an explicit error would at least be honest. That call is a maintainer's to make.

    Minimal repro (tests/page/)
    test('response.body() after in-page blob() consumption', async ({ page, server }) => {
      const body = Buffer.alloc(50000, 0x50);
      server.setRoute('/export', (req, res) => {
        res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(body.length) });
        res.end(body);
      });
      await page.goto(server.EMPTY_PAGE);
      const [response] = await Promise.all([
        page.waitForResponse(r => r.url().includes('/export')),
        page.evaluate(async () => {
          const response = await fetch('/export');
          const blob = await response.blob();
          (window as any).__blobSize = blob.size;
        }),
      ]);
      expect(await page.evaluate(() => (window as any).__blobSize)).toBe(50000); // 50000
      expect((await response.body()).length).toBe(50000); // 0 in chromium
    });
    What I ran
    • tip-of-tree, sha 0a2a33c487b260359a9f82ffe661c551a168bf62 (1.64.0-next), your standalone script verbatim → response.body() 0 bytes, blob.size 50000. Fails.
    • published 1.62.1, same script, clean install → 50000 bytes. Passes.
    • Condensed spec above, headless, Linux:
      • chromium — fails, 0 instead of 50000, reproduced on all 4 attempts (no retry flakiness)
      • firefox — passes
      • webkit — passes
    • Body size was never the issue: the browser has the full 50000 bytes in both the failing and passing runs, only the Node-side read differs.
    • Source traced to kRefetchSafeResourceTypes in packages/playwright-core/src/server/chromium/crNetworkManager.ts:567 and the early return Buffer.from('') at line 621.
    • Searched open and closed issues and PRs for prior reports of an empty response.body() after in-page consumption — nothing matching; this isn't a duplicate.

    One note on workarounds while this is open: the body is only evicted once the page reads it, so page.route('**/export', ...) with route.fetch() still gives you the bytes, as does asserting on the in-page blob directly.

    Triaged on Linux by the Playwright bot - agent run

  2. woppa684 commented on Sep 25, 2026

    @woppa684

    Can this also be the cause of lots of Failed to load resource: net::ERR_NO_BUFFER_SPACE (port exhaustion) we are currently observing? It is very hard to track where these are coming from, but if there is a fetch problem, it might leave TCP connections open, as can be seen in, for example, this bug node-fetch/node-fetch#1673
    (sorry for semi-hijacking this topic, but at the moment we're really blocked so I'm grasping everything that might be related).

  3. hardikkaurani commented on Sep 25, 2026

    @hardikkaurani

    Hi maintainers, I've investigated #42862 and reproduced the reported regression locally. I'd like to work on this issue and prepare a focused fix with regression coverage.

    Would it be possible to assign this issue to me, or otherwise confirm that it is open for a community contribution?

    Thanks!

  4. crisdosaygo commented on Sep 28, 2026

    @crisdosaygo

    Evan Rusackas (@rusackas) one thing worth considedring on your #42863: going by the bot's trace, the 50000 bytes 1.62.1 gave you were already a re-fetch, not the response the page read. A re-fetched GET is a second response. For a plain static export that's the same bytes, but anything with a single-use token, a signed URL that has since expired, or a generate-on-request step (timestamps, row order) means the test asserts on something the app never saw, and the export endpoint gets hit twice.

    If you want the exact bytes the browser received without a second request, you can take them before the page does, at the Fetch domain's response stage (I haven't tested the below in playwright, but it is close to waht I use in raw CDP):

    const cdp = await page.context().newCDPSession(page);
    await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*/export*', requestStage: 'Response' }] });
    const exported = new Promise(resolve => {
      cdp.on('Fetch.requestPaused', async ({ requestId }) => {
        const { body, base64Encoded } = await cdp.send('Fetch.getResponseBody', { requestId });
        await cdp.send('Fetch.continueRequest', { requestId });
        resolve(Buffer.from(body, base64Encoded ? 'base64' : 'utf8'));
      });
    });
    await page.click('#go');
    const bytes = await exported;

    While the response is paused the browser still holds the body, so there's nothing to evict. Costs: the page only gets the response after the whole body has been read (fine for an export, not for a long stream; Fetch.takeResponseBodyAsStream is the streaming variant), and don't overlap it with page.route on the same URLs, since Playwright's routing drives the same domain.

    Reading the paused response is also how DownloadNet/22120/tdf, an archiver I wrote, captures bodies.

  5. rusackas commented on Oct 8, 2026

    @rusackas
    Author

    Cris (@crisdosaygo) good point, the re-fetch isn't the bytes the page actually saw, it's a second request. Fine for a static export, but for a single-use token or anything generated per-request it'd be asserting on the wrong thing entirely.

    Re-reading the bot's first comment, I think that's the better fix: fail loudly when the body's been evicted and can't be safely recovered, instead of silently re-fetching (my PR) or silently returning empty (current behavior). Keeps the original side-effect-free intent from #42124/#42201 intact and at least tells you something's wrong instead of lying about it.

    Your CDP approach is clever, but hooking the Fetch domain on every response seems like a bigger lift given it'd clash with page.route and block streaming responses until they're fully downloaded. Might be worth its own issue if there's appetite for response.body() to guarantee exact bytes in general.

    Not likely to pick this back up myself any time soon, but happy to take a stab at the fail-loudly version if a maintainer thinks it's the way to go.

  6. crisdosaygo commented on Oct 15, 2026

    @crisdosaygo

    Evan Rusackas (@rusackas) yep, failing loudly is the right default here. A successful call returning an empty buffer is a nasty thing for a test to trust.

    Just to clarify the CDP suggestion: I meant opting in for one URL pattern (*/export* in that example), not intercepting every response. For a finite export where the test needs the exact bytes the page received, waiting for the body can be an acceptable tradeoff.

    I agree it shouldn't become the general implementation of response.body(). The streaming and page.route caveats still apply. I was suggesting a narrow recipe for that exact-byte case, not a transparent replacement for the existing API.

  7. rusackas commented on Oct 15, 2026

    @rusackas
    Author

    Cris (@crisdosaygo) Thanks for the reply. The fail-loud direction already gad a shot on #42966 (throwing instead of returning empty), and Dmitry Gozman (@dgozman) (reasonably) closed it as a breaking change. I suppose neither of the two approaches floated here so far is going to land as-is.

    The one variant I don't think anyone's proposed yet would be to make it opt-in. Something like response.body({ strict: true }) that throws when the body's been evicted and can't be safely recovered, while the default (no argument) keeps returning the empty buffer exactly as today. Nothing changes for existing callers either way, so it shouldn't hit the same objection.

    Dmitry Gozman (@dgozman) would that approach be worth a PR, or is the empty-buffer behavior just not something you want a second code path for either way? Happy to help if I'm able to.

  8. yury-s commented on Oct 20, 2026

    @yury-s
    Member

    Fix landed upstream https://chromium-review.googlesource.com/c/chromium/src/+/8451629 and should be rolled into Playwright soon.

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