Skip to content

[🐞] <Catch> during SSR: Cache-Control: no-store is never sent in a Qwik Router app (the first chunk always leaves before any route component renders) #9268

Open

Description

@Pancio-code

Which component is affected?

Qwik Runtime

Describe the bug

Version: @qwik.dev/core / @qwik.dev/router 2.0.0-rc.3, and main at e63f0b2 (pkg.pr.new build). experimental: ["catchBoundary", "pendingBoundary", "blockSSR"].

What happens

The Catch docs (Caching) say:

An error caught during SSR still responds 200, so the framework refuses to let that response be
cached: it sends Cache-Control: no-store when the error precedes the first chunk […]

In a Qwik Router app this never happens, even when a route component throws synchronously on its
first render. The response is 200 with no Cache-Control header. The router's
onBeforeFirstFlush callback, which sets no-store, always gets { hasCaughtError: false }.

The cause: in _walkJSX, when a component$ body returns a promise, the walker flushes the buffer
before awaiting it (await ssr.streamHandler.flush(), "Defer reading QScopedStyle until after the
promise resolves"). The root component (useQwikRouter()) has an async body, so the first flush
happens right after <html …> is written (209 bytes in the repro), before <head>, the layout or
the route. That flush is the first write, so onBeforeFirstFlush runs there and the response
headers leave. It happens with every in-order strategy: auto with a large
maximumInitialChunk and disabled give the same result.

Minimal reproduction

import { $, Catch, component$ } from "@qwik.dev/core";

/** Throws while rendering on the server. */
const Broken = component$(() => {
  throw new Error("E-MARKER: render failed");
});

export default component$(() => (
  <Catch fallback$={$((error) => <p id="fallback">fallback: {error.message}</p>)}>
    <Broken />
  </Catch>
));
GET /e/  →  HTTP 200, no Cache-Control header, fallback rendered

Expected per the docs: Cache-Control: no-store, since nothing of the route was streamed before
the error.

Things that don't change the result (rc.3 and main e63f0b2, dev and production build):

  • streaming: { outOfOrder: false };
  • streaming: { inOrder: { strategy: "auto", maximumInitialChunk: 100000 } };
  • streaming: { inOrder: { strategy: "disabled" } };
  • a renderToStream onBeforeFirstFlush that wraps the router's: it is called once, with
    {"hasCaughtError":false};
  • getRequestEvent().headersSent inside the boundary's onError$ is already true, so the app
    can't set a status there either.

The SSR cache part works (result.hasCaughtError is read after the render). Only the header part,
and anything else that relies on onBeforeFirstFlush's hasCaughtError, is unreachable.

Expected

One of:

  • the first flush waits until something of the app body is ready (or at least respects the in-order
    strategy, so disabled / maximumInitialChunk can hold it back), so an early caught error can
    still set no-store (and a status, from an app hook);
  • or, if flushing <html> early is intended, the docs say that no-store is only possible without
    an async root, and point to the alternative (error responses from loaders / request handlers,
    as docs: Catch makes erroring routes uncacheable — document after RC #8913 plans to document).

Related

Reproduction

https://github.com/Pancio-code/qwik2-rc3-repros

Steps to reproduce

git clone https://github.com/Pancio-code/qwik2-rc3-repros
cd qwik2-rc3-repros && npm install
npm run build.client && npm run build.preview
node verify-e.mjs
E_STATUS=1 E_INORDER=disabled node verify-e.mjs /e/

src/entry.ssr.tsx reads the flags: E_STATUS=1 wraps onBeforeFirstFlush (logs info, and on
hasCaughtError sets 500), E_CHUNK=n sets maximumInitialChunk, E_INORDER=disabled, OOS=0.
E_APP=1 makes the boundary's onError$ log headersSent. Output on rc.3 (build):

build
/e/      HTTP 200, cache-control=null, fallback="fallback: An error occurred"
    server: (nothing)
/e-late/ HTTP 200, cache-control=null, fallback="fallback: An error occurred"
    server: (nothing)
build E_STATUS=1 E_CHUNK=100000
/e/      HTTP 200, cache-control=null, fallback="fallback: An error occurred"
    server: [E] onBeforeFirstFlush {"hasCaughtError":false}
build OOS=0 E_STATUS=1 E_INORDER=disabled
/e/      HTTP 200, cache-control=null, fallback="fallback: An error occurred"
    server: [E] onBeforeFirstFlush {"hasCaughtError":false}
build E_APP=1
/e/      HTTP 200, cache-control=null, fallback="fallback: An error occurred"
    server: [onError$] headersSent=true

System Info

System:
    OS: Linux 7.0 Ubuntu 26.04.1 LTS 26.04.1 LTS (Resolute Raccoon)
    CPU: (32) x64 Intel(R) Core(TM) i9-14900HX
    Container: Yes
    Shell: 5.3.9 - /bin/bash
  Binaries:
    Node: 24.21.0 - /home/pancio_o/.nvm/versions/node/v24.21.0/bin/node
    npm: 11.19.0 - /home/pancio_o/.nvm/versions/node/v24.21.0/bin/npm
  Browsers:
    Firefox: 158.0
    Firefox Developer Edition: 158.0
  npmPackages:
    @qwik.dev/core: 2.0.0-rc.3 => 2.0.0-rc.3
    @qwik.dev/router: 2.0.0-rc.3 => 2.0.0-rc.3
    typescript: 5.9.3 => 5.9.3
    vite: 8.2.1 => 8.2.1

Additional Information

Found in a production app moving to 2.0.0-rc.3: we wanted page-level SSR errors to answer 5xx and
saw that neither no-store nor a status set from onError$ ever reaches the response. The 200
itself and the silence in the server logs are documented (report through onError$), so we don't
ask to change those.

Activity

  1. added a commit that references this issue on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions