Repository navigation
[Regression 1.62.1 → 1.63.0]: page Response.body() returns empty after in-page fetch().blob() already consumed it #42862
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.sizeis 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
github-actions commentedon Sep 22, 2026
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, sha0a2a33c487b260359a9f82ffe661c551a168bf62) 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.getResponseBodycomes 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: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/)What I ran
0a2a33c487b260359a9f82ffe661c551a168bf62(1.64.0-next), your standalone script verbatim →response.body()0 bytes,blob.size50000. Fails.chromium— fails,0instead of50000, reproduced on all 4 attempts (no retry flakiness)firefox— passeswebkit— passeskRefetchSafeResourceTypesinpackages/playwright-core/src/server/chromium/crNetworkManager.ts:567and the earlyreturn Buffer.from('')at line 621.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', ...)withroute.fetch()still gives you the bytes, as does asserting on the in-pageblobdirectly.Triaged on Linux by the Playwright bot - agent run
woppa684 commentedon Sep 25, 2026
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).
hardikkaurani commentedon Sep 25, 2026
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!
crisdosaygo commentedon Sep 28, 2026
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):
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.takeResponseBodyAsStreamis the streaming variant), and don't overlap it withpage.routeon 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.
rusackas commentedon Oct 8, 2026
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.routeand block streaming responses until they're fully downloaded. Might be worth its own issue if there's appetite forresponse.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.
crisdosaygo commentedon Oct 15, 2026
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 andpage.routecaveats still apply. I was suggesting a narrow recipe for that exact-byte case, not a transparent replacement for the existing API.rusackas commentedon Oct 15, 2026
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.
[DevTools] Report fetch() bodies consumed as blobs to the inspector
yury-s commentedon Oct 20, 2026
Fix landed upstream https://chromium-review.googlesource.com/c/chromium/src/+/8451629 and should be rolled into Playwright soon.