Investigation report

Railway's edge proxy buffers compressed streaming responses

Wasp kitchen-sink e2e failure on Railway deployments · June 10, 2026 · edge: railway-hikari (europe-west4 / ams)

TL;DR — When a browser requests a chunked (streaming) HTTP response with a compressible content type, Railway's edge proxy gzips it on the fly and holds all output until the response completes, so the client receives the entire stream as a single chunk at the end. This silently breaks incremental delivery for any non-SSE streaming endpoint. Workarounds that bypass it: Cache-Control: no-transform or Content-Type: text/event-stream.

1. Context

Wasp's CI deploys the kitchen-sink example app to both Fly.io and Railway, then runs the same Playwright suite against each deployment. On Railway, exactly one test failed, deterministically, on every retry and in both browser projects (CI run):

[chromium] › streaming.spec.ts:3:3 › streaming › text streams over time

Error: expect(locator).toContainText(expected) failed
Locator:  locator('p')
Expected: "Hm, let me see..."
Received: ""
Timeout:  5000ms
  6 × locator resolved to <p class="max-w-2xl"></p>
      - unexpected value ""

The other 30 tests passed on Railway, and the full suite (including this test) passed on Fly.io and locally. The page element rendered, CORS worked, the request returned 200, yet not a single streamed byte reached the page within the 5 second window.

2. The streaming endpoint

The server is Express (Node.js). The endpoint writes 11 small chunks over ~5 seconds, then ends the response. Total payload is ~300 bytes.

// src/features/streaming/api.ts
export const streamingText: StreamingText = async (_req, res, _context) => {
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  res.setHeader("Transfer-Encoding", "chunked");

  let counter = 1;
  res.write("Hm, let me see...\n");
  while (counter <= 10) {
    if (counter === 10) {
      res.write(`and finally about ${counter}.`);
    } else {
      res.write(`let's talk about number ${counter} and `);
    }
    counter++;
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  res.end();
};

The client reads it with a plain fetch body reader and appends chunks to the page as they arrive.

3. Reproduction

Deploy any Express app with the endpoint above to Railway, then read the response body chunk by chunk with timestamps. The Node script below mirrors exactly what a browser's fetch reader does:

// stream-timing.mjs
const url = "https://<your-server>.up.railway.app/api/streaming-test";
const start = Date.now();
const res = await fetch(url, {
  headers: { "Accept-Encoding": "gzip, deflate, br, zstd" }, // what every browser sends
});
console.log("content-encoding:", res.headers.get("content-encoding"));
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(((Date.now() - start) / 1000).toFixed(2) + "s", JSON.stringify(dec.decode(value)));
}

Actual result (browser-like Accept-Encoding)

content-encoding: gzip
5.24s "Hm, let me see...\nlet's talk about number 1 and let's talk about number 2 and ... and finally about 10."
total: 5.24s   // everything in ONE chunk, only after res.end()

Same endpoint, no Accept-Encoding header (e.g. bare curl)

content-encoding: null
0.31s "Hm, let me see...\nlet's talk about number 1 and "
0.81s "let's talk about number 2 and "
1.31s "let's talk about number 3 and "
   ...one chunk every ~500ms...
4.81s "and finally about 10."
total: 5.35s

The only difference between the two requests is the Accept-Encoding header. Since every browser sends it, every browser user gets the buffered behavior. The response also carries vary: accept-encoding and server: railway-hikari, confirming the edge applies the compression (the origin app has no compression middleware).

4. Investigation steps

  1. Rule out the test and the app

    Ran the same Playwright spec against a local dev run of the app: 2 passed. The identical suite also passes against the Fly.io deployment in the same CI runs, so the app, test, and CORS setup are all sound.

  2. Deploy a fresh Railway project

    Deployed kitchen-sink to a clean Railway project (wasp-cli deploy railway launch, Railway CLI 5.8.0) to test the endpoint in isolation, outside CI.

  3. Confirm the server streams correctly

    Plain request without Accept-Encoding: 11 chunks, one every ~500ms, first chunk at 0.31s. Streaming works — so Railway's proxy does not buffer uncompressed responses.

  4. Reproduce with browser headers

    Added Accept-Encoding: gzip, deflate, br, zstd: response comes back content-encoding: gzip and the entire body arrives as one chunk at 5.24s, right after the server calls res.end(). Streaming broken — this also explains the Playwright failure: the first assertion times out at 5.00s, just before the buffered payload lands.

  5. Verify the escape hatches

    Setting Cache-Control: no-transform on the response: edge skips gzip, chunks stream every ~500ms. Works

    Setting Content-Type: text/event-stream (no other changes): edge skips gzip, chunks stream every ~500ms. Works — SSE is special-cased, which is why typical AI chat apps don't hit this.

  6. Confirm end to end

    With the no-transform header deployed, the previously failing Playwright spec passes against the Railway deployment: 2 passed (6.3s).

5. The fix (workaround)

One header on the streaming endpoint:

res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Transfer-Encoding", "chunked");
res.setHeader("Cache-Control", "no-transform"); // tell intermediaries not to re-encode

no-transform (RFC 9111 §5.2.2.6) is the standard directive telling intermediaries not to transform the payload, and Railway's edge honors it.

6. How other edges handle this

EdgeCompression of streaming (unknown-length) responsesStreaming outcome
Railway (hikari) Gzips chunked responses and buffers all output until the response ends (measured). Honors no-transform; exempts text/event-stream. Broken by default
AWS CloudFront Requires a valid Content-Length (1 KB–10 MB) to compress; responses without it are never compressed. Safe by design
Cloudflare Compresses on the fly; documents cache-control: no-transform as the opt-out. Documented opt-out
Vercel Compresses only an allowlist of MIME types; text/event-stream and text/html are not on it. Safe by allowlist
Fly.io (fly-proxy) No on-the-fly compression observed; identical app streams correctly (empirical, same CI suite). Pass-through

7. Suggested report to Railway

The edge's gzip is correct for buffered responses but breaks incremental delivery for streams. Two standard remedies, either of which would fix this class of issue:

Reproduced June 10, 2026 against server: railway-hikari, x-railway-edge: railway/europe-west4-drams3a, HTTP/2, origin: Express on Node 24 (Railway Metal builders, Railway CLI 5.8.0). Origin app has no compression middleware; vary: accept-encoding is added by the edge.