Repository navigation
Description
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 sendsCache-Control: no-storewhen 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
renderToStreamonBeforeFirstFlushthat wraps the router's: it is called once, with
{"hasCaughtError":false}; getRequestEvent().headersSentinside the boundary'sonError$is alreadytrue, 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, sodisabled/maximumInitialChunkcan hold it back), so an early caught error can
still setno-store(and a status, from an app hook); - or, if flushing
<html>early is intended, the docs say thatno-storeis 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
- feat(core): core ErrorBoundary in SSR and CSR #8745 added the SSR
Catchwithno-store; docs: Catch makes erroring routes uncacheable — document after RC #8913 (open) is the docs follow-up about caching.
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.1Additional 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
README: /q34*/ (QwikDev/qwik#9267) and /e/ (QwikDev/qwik#9268) with v…