Skip to content

[Bug]: response.body() returns mojibake (double-encoded UTF-8) for SSE streaming responses #39812

@IamHuskar

Description

@IamHuskar

Version

1.54.0

Steps to reproduce

git clone https://github.com/IamHuskar/playwright_sse_mojibake
npm install
npx playwright install
npm run repro

or
Start the server in one terminal:

npm run server

Run the client in another terminal:

npm run client

server.ts

import { createServer } from "node:http";
import { fileURLToPath } from "node:url";

const port = 5000;

const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>SSE Test</title>
</head>
<body>
  <h1>SSE Test Page</h1>
  <button id="btn">Start SSE</button>
  <script>
    document.getElementById("btn").addEventListener("click", () => {
      const evtSource = new EventSource("/sse");
      evtSource.onmessage = event => console.log(event.data);
      evtSource.onerror = () => evtSource.close();
    });
  </script>
</body>
</html>
`;

const messages = ["你好,这是第一条消息", "测试中文:"];

export function startServer() {
  const server = createServer((req, res) => {
    const url = new URL(req.url ?? "/", `http://${req.headers.host}`);

    if (url.pathname === "/") {
      res.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
        "Cache-Control": "no-cache"
      });
      res.end(html);
      return;
    }

    if (url.pathname === "/sse") {
      res.writeHead(200, {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache",
        Connection: "keep-alive"
      });

      let index = 0;
      const timer = setInterval(() => {
        if (index >= messages.length) {
          clearInterval(timer);
          res.end();
          return;
        }

        res.write(Buffer.from(`data: ${messages[index]}\n\n`, "utf8"));
        index += 1;
      }, 300);

      req.on("close", () => {
        clearInterval(timer);
        res.end();
      });

      return;
    }

    res.writeHead(404, {
      "Content-Type": "text/plain; charset=utf-8"
    });
    res.end("Not Found");
  });

  server.listen(port, () => {
    console.log(`SSE server listening on http://localhost:${port}`);
  });

  return server;
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  startServer();
}

client.ts

import { chromium } from "playwright";
import { fileURLToPath } from "node:url";

function printBuffer(label: string, body: Buffer) {
  console.log(`\n[${label}]`);
  console.log(`  Raw bytes: ${body.toString("hex")}`);
  try {
    console.log(`  Decoded:   ${JSON.stringify(body.toString("utf8"))}`);
  } catch (error) {
    console.log(`  Decoded:   <decode failed: ${String(error)}>`);
  }
}

export async function main() {
  const headless = process.env.HEADLESS === "1";
  const browser = await chromium.launch({ headless });
  const page = await browser.newPage();
  const cdp = await page.context().newCDPSession(page);

  await cdp.send("Network.enable");

  const sseRequestIds = new Set<string>();

  cdp.on("Network.responseReceived", params => {
    if (typeof params.response?.url === "string" && params.response.url.includes("/sse")) {
      sseRequestIds.add(params.requestId);
    }
  });

  cdp.on("Network.loadingFinished", async params => {
    if (!sseRequestIds.has(params.requestId)) {
      return;
    }

    try {
      const result = await cdp.send("Network.getResponseBody", { requestId: params.requestId });
      console.log("\n[CDP Network.getResponseBody()]");
      console.log(`  base64Encoded: ${result.base64Encoded}`);
      console.log(`  body type: ${typeof result.body}`);
      console.log(`  body: ${JSON.stringify(String(result.body).slice(0, 120))}`);
    } catch (error) {
      console.log(`\n[CDP Network.getResponseBody()] failed: ${String(error)}`);
    }
  });

  await page.route("**/sse", async route => {
    const response = await route.fetch();
    const body = Buffer.from(await response.body());
    printBuffer("route.fetch() - CORRECT", body);
    await route.fulfill({ response });
  });

  page.on("response", async response => {
    if (!response.url().includes("/sse")) {
      return;
    }

    try {
      const body = Buffer.from(await response.body());
      printBuffer("response.body() - BUG", body);
    } catch (error) {
      console.log(`\n[response.body() - BUG] failed: ${String(error)}`);
    }
  });

  await page.goto("http://localhost:5000");
  await page.click("#btn");
  await page.waitForTimeout(3000);
  await browser.close();
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  main().catch(error => {
    console.error(error);
    process.exitCode = 1;
  });
}

Expected behavior

response.body() should return the same raw UTF-8 bytes as the server sent, matching route.fetch():

[route.fetch() - CORRECT]
  Decoded:   "data: 你好,这是第一条消息\n\ndata: 测试中文:\n\n"

[response.body() - CORRECT]
  Decoded:   "data: 你好,这是第一条消息\n\ndata: 测试中文:\n\n"

Actual behavior

response.body() returns mojibake / double-encoded bytes, while route.fetch() is correct:

[route.fetch() - CORRECT]
  Raw bytes: 646174613a20e4bda0e5a5bdefbc8ce8bf99e698afe7acace4b880e69da1e6b688e681af0a0a646174613a20e6b58be8af95e4b8ade69687efbc9a0a0a
  Decoded:   "data: 你好,这是第一条消息\n\ndata: 测试中文:\n\n"

[response.body() - BUG]
  Raw bytes: 646174613a20c3a4c2bdc2a0c3a5c2a5c2bdc3afc2bcc592c3a8c2bfe284a2c3a6cb9cc2afc3a7c2acc2acc3a4c2b8e282acc3a6c29dc2a1c3a6c2b6cb86c3a6c281c2af0a0a646174613a20c3a6c2b5e280b9c3a8c2afe280a2c3a4c2b8c2adc3a6e28093e280a1c3afc2bcc5a10a0a
  Decoded:   "data: ä½ å¥½ï¼Œè¿™æ˜¯ç¬¬ä¸€æ¡æ¶ˆæ¯\n\ndata: 测试中文:\n\n"

[CDP Network.getResponseBody()]
  base64Encoded: false
  body type: string
  body: "data: ä½ å¥½ï¼Œè¿™æ˜¯ç¬¬ä¸€æ¡æ¶ˆæ¯\n\ndata: 测试中文:\n\n"

Additional context

@pavelfeldman
I apologize, the original issue (microsoft/playwright-python#3023) followed a template and included the reproduction steps, so I did not fill in all the information fields. I have now reproduced the problem using TypeScript.

Environment

System:
    OS: Windows 11 10.0.22621
    CPU: (36) x64 Intel(R) Xeon(R) CPU E5-2696 v3 @ 2.30GHz
    Memory: 6.21 GB / 31.91 GB
  Binaries:
    Node: 24.14.0 - C:\Program Files\nodejs\node.EXE
    npm: 11.9.0 - C:\Program Files\nodejs\npm.CMD
  IDEs:
    VSCode: 1.111.0 - C:\Users\admin\AppData\Local\Programs\Microsoft VS Code\bin\code.CMD
    Claude Code: 2.1.78 - C:\Users\admin\AppData\Roaming\npm\claude.CMD
    Codex: 0.115.0 - C:\Users\admin\AppData\Roaming\npm\codex.CMD
  Languages:
    Bash: 5.1.16 - C:\Windows\system32\bash.EXE
  npmPackages:
    playwright: 1.54.0 => 1.54.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    browser-chromiumupstreamThis is a bug in something playwright depends on, like a browser.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions