Repository navigation
RFC: Qwik v2 error handling — PublicError, fail(), and ErrorBoundary #358
Closed
maiieul
started this conversation in
Proposals For Qwik
Replies: 1 comment
|
Update (2026-08-04): the converged v2 error design drops Landed on the #8745 branch: the class and its serdes are removed, redaction is now origin-based, and pages whose boundary caught before the first flush — plus error documents — send |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
1. The model
Every error in a Qwik app is exactly one of three kinds:
throw new PublicError(data)— core class, free import, usable anywheresignal.error, read via a guard; page keeps rendering. Unguarded.valuereads re-throw it into the closest boundary — never a silent failurethrow fail(status, data)— event-arg method, loaders + middleware onlyerror.tsxchain (or generic error page)<ErrorBoundary>; redacted in prodFour principles generate everything else:
framework handles it server-side (never
instanceof, never.datain app code). You read aninline error back at
loader.error. So the abort is an ergonomic FUNCTION (a verb — an actionyou take) and the inline is a CLASS (a noun — a value you hold and read). The syntactic distance
(
fail(...)call vsnew PublicError(...)) is itself the disambiguator.objects must be thrown (a resolved
PublicErroris a dev-time error, not a secret throw), andreturned values are always data. The single, deliberate exception is the deprecated v1 compat
channel
return fail()→.value.failed.are data — no status field, no status side effects (see §6 for the research grounding).
PublicErroris consent to serialize and display its data.Everything else fails closed: redacted, never serialized, never rendered.
Where any throw ends up:
flowchart TD T["throw / rejection inside a loader or async computed"] --> P{"instanceof PublicError?"} P -- "yes" --> INL["inline: stored on signal.error<br/>page keeps rendering, status untouched"] INL --> G{"component guards .error?"} G -- "yes — early return" --> UI["friendly error UI renders"] G -- "no — .value is read" --> RT["LOUD: .value re-throws the PublicError<br/>(an unguarded inline error never fails silently)"] RT --> EB P -- "no" --> F{"branded fail() carrier?"} F -- "yes, pre-stream" --> AB["abort: response status set,<br/>error.tsx chain renders"] F -- "yes, but stream started<br/>(blockSSR:false)" --> U F -- "no" --> U["unexpected: internal rejection<br/>(never on .error — reads it as undefined —<br/>never serialized)"] U --> R[".value read re-throws it"] R --> EB["closest ErrorBoundary renders fallback"] EB -. "no boundary" .-> ESC["render rejects → generic error page"]2. The inline channel:
PublicErrorDesign decisions, each with its rationale:
Error. Unexpected errors (TypeError, fetch/db failures) are alsoinstanceof Error, so bare-Error classification would make every accident "public" — internalmessages would serialize into HTML and render through guards. Public errors are deliberate
(the developer marks them); unexpected ones are accidents (nobody marks them) — the marker must
live on the deliberate side and be checkable in
$setError$, the.errorgetter, and theserializer, failing closed. Every surveyed ecosystem draws the expected/unexpected line at
"did app code use the blessed constructor" (SvelteKit
error()bypassinghandleError, h3'sunhandledflag, NestJSHttpException).broken
instanceof+ guard-function workarounds ever since (#10361, #12611); a public class withfirst-class serdes support avoids that whole issue class.
statusfield — not even optional. No surveyed framework puts HTTP status on itsinline/readable channel; tRPC/Apollo keep the semantic field (
code) on the error and derivetransport status. A field that is inert in loaders but honored in
server$would recreateNuxt's documented dual-personality confusion (
createErrorfatal on server, inert on client),and status is precisely the field that made the removed
ServerErrorabort-shaped. If userlandbranching pressure ever appears, the right extension is a tRPC-style
codefield — never astatus field. A
codefield is not part of this design.PublicErroris a bug. No framework supports Error-instancereturn/throw parity on purpose (React Router refused it with a typing rationale; SvelteKit
dev-enforces verb correctness; graphql-js's
instanceof-on-resolve behavior is a documentedgotcha that makes Error instances unrepresentable as data). When a loader/computed resolves
with a
PublicErrorinstance, the framework raises a qError ("throw it instead") which flowsdown the unexpected channel — loud in dev and prod, no silent magic. A returned plain
Errorstays an ordinary value. Optional type-level hardening: constrain compute/loader return types so
PublicErrorin the resolved type is a compile error.thrown values cannot contribute to derived types, which is exactly why SvelteKit enforces
return fail()for forms, and Next.js documents "model expected errors as return values".PublicErrorstays single-purpose: loud, gating, read at.error.instanceofsurvives the wire (resumed.errorstate, loader.json envelope). Classifiers additionally accept a well-known Symbol brand on the
class, so a duplicated
@qwik.dev/corecopy cannot silently break classification (the dual-copyinstanceoffailure is a documented TanStack Form hazard).(deser-proxy gates
typeId >= TypeIds.Error); serialize branch BEFORE genericinstanceof Error; keep dump-state typeIdToName in sync. Optional dev-mode eagerserializability check on
datain$setError$.3. The abort channel:
fail(status, data)({ fail }) => …) andmiddleware/endpoints (
onRequest(({ fail }) => …)) — promoted fromRequestEventActiontoRequestEventCommon(a non-breaking addition). NOT a free import, NOT on components/client.This makes abort structurally server-only: a client-capable async computed has no
fail()inscope, so it cannot abort.
server$and actions share the RequestEvent type but areimperative calls, not document renders — their error path is
PublicError, andfail()isomitted from their
thistypes.fail()sets the response status at call time (v1 behavior) and returns the failurecarrier. THROWING the carrier is the abort; RETURNING it is the deprecated v1 inline channel
(
.value.failed). Same function — the verb routes it unambiguously.Errorsubclass ("FailReturn"), not a plain object, carrying ownenumerable
failed: trueplus the spread data keys:throw fail(...)stays lint-clean —throw {failed: true}fails@typescript-eslint/only-throw-error(in recommended-type-checked), airbnb, and standard,i.e. most typed apps in 2026 — and carries a stack for debugging.
return fail(...)compat holds:.value.failed === trueand data keys read the same, and theError serializer (message + own enumerable props) preserves the shape across the wire.
.value.failedconsumers, JSONexpectations, and the serdes round-trip must keep reading identically (pinned by C5/D20).
failed === true. A rethrown API responsebody that happens to contain
failed: truemust NOT abort the request with a stale status.Aborts never serialize (the request ends server-side), so a Symbol is safe, and it is immune to
package-duplication
instanceofhazards. One predicate at every document catch site(loaders-middleware classifier, user-response catch, serverErrorMiddleware).
instanceofin appcode. (A named class was considered and dropped;
AbortErrorwould also collide with the DOMcancellation error that async signals swallow.)
error(status, data)is kept as a deprecated shim producing the same branded carrier(deprecation message: "throw fail(status, data)"). Because
ServerErrorleaves the control-flowset,
return error()no longer aborts — it is an inert value (v1 no-op), undocumented.ServerErroris REMOVED (breaking; it was v2-beta-only). Data-shapereconciliation: ServerError was
{status, data}(any data); the carrier spread isobject-only — string messages become
{ message: '…' }; internalthrow new ServerError(status, 'string')sites (rewrite 400, malformed-URL 404) migrate toobject data; mind
fail()'scheck()(it throws after headers are sent — the malformed-URL 404completion stays a bare post-headers object, not an
ev.fail()call).throwIfControlFlowSignalkeeps only AbortMessage/RedirectMessage/RewriteMessage. The carrieris never in it — a returned
fail()stays.value.faileddata.v3 end state:
throw fail(status, data)is the only abort;throw new PublicError(data)is theonly inline channel;
error(),return fail()/.value.failedare removed.4. Read semantics on async signals
All three getters compute-if-needed; on the server each throws the pending compute promise so SSR
always renders settled results (component bodies retry via the existing promise-retry machinery).
PublicError).value.errorundefinedPublicErrorundefined(falls through to.value→ boundary).pendingfalseafter settle; never throws errorsLoaders/async computeds are LOUD. The guard is a plain early return:
Forgetting the guard on code that can produce a
PublicErroris a loud throw into the nearestboundary — the desired failure mode. The loud throw is conditional on an error existing at
runtime, not on the code containing a
throw: a plain data loader just reads.value, no forcedguard. (
.errorstays typedPublicError | undefined— throws aren't statically detectable.)$setError$classification: DOMAbortError→ ignored (cancellation);instanceof PublicError(or brand) →
$untrackedError$(serializes); anything else → internal rejection slot (neverserializes — the signal serializes as invalid/NEEDS_COMPUTATION so the client recomputes).
Timeout errors (the
timeoutoption) are infrastructure → unexpected channel.A thrown fail() carrier inside a client-capable async computed is not a
PublicError→unexpected → boundary (+ dev warn). Correct: client computes cannot abort an HTTP response.
.valuesetter clears both error channels; a successful recompute clears.error.5. Surface by surface
One error model across every consumer:
await fn(args).submit(args).pendingawait).pending.valueawaitreturn value.valuePublicError(loud).error; unguarded.valuere-throws → boundaryawaitrejects with it; unhandled → boundary.error; same gating as loaders.formErrors(string[]).fieldErrors(validator orinvalid())throw fail(status, d)awaitrejects generic; unhandled → boundary.submit()rejects; unhandled → boundaryTwo rules generate the table: throw = loud/gating (
PublicError→.error;fail()→error.tsx; unexpected → boundary) and soft = validation/returned data (
.formErrors/.fieldErrors/.value)..errorbehaves identically in every column;fail()exists onlywhere there is a document to abort.
5.1 Loaders
What a loader author writes and what happens:
throw new PublicError(d)signal.error, page renders, status untouchedreturn new PublicError(d)throw fail(s, d)s+ error.tsx chain (blessed)return fail(s, d).value.failed, page renders, statuss(deprecated)throw error(s, d)throw fail()return error(s, d)throwanything elsereturnplainErrorRequest lifecycle for a guarded inline error (SSR document request):
sequenceDiagram participant MW as loadersMiddleware participant L as loader fn participant SIG as loader signal participant C as route component MW->>L: settle before streaming (blockSSR default) L-->>MW: throws PublicError MW->>SIG: record rejection (request-scoped) Note over MW: chain continues — no abort,<br/>status stays 200, headers not sent yet C->>SIG: .error read (SSR waits for settled state) SIG-->>C: the PublicError C->>C: early-return guard renders the friendly branchrejections: branded fail carrier → rethrow to the chain →
serverErrorMiddlewareaborts withstatus + error.tsx (today's machinery; status was set at
fail()call time);PublicError→recorded for the signal, chain continues; unexpected → recorded as internal rejection (boundary
at render). Redirects still short-circuit.
settle and the signal compute at render (
capture.load()). Success is memoized today; failureis erased (
delete promises[loaderId]), so under this contract a failed loader would re-run atrender — double side effects and a nondeterministic page (a
decrementStock()loader could failin middleware, then succeed on the re-run). Fix is symmetric memoization in the same
per-request store: record the settled rejection;
load()rethrows it. All the stores live onrequestEv.sharedMap, which is created per incoming request — so cross-request/cross-user errorcaching is structurally impossible and every retry path (in-loader retries,
invalidate(), SPAnavigation, resubmit) is a fresh request. Record ALL rejections including aborts: the error.tsx
chain re-render must not re-run the crashed loader.
throw fail()/error()and loaderabort throws. The error render must not re-run route middleware or the crashed loader (Nuxt
documents middleware re-execution on its error page as a loop footgun — pinned by test).
A
fail()thrown from a layout loader renders the chain level above the crashed layout(pinned by e2e).
{ d | r | e }:{e: PublicError}on HTTP 200 (TypeId keepsinstanceof) → client.error,SPA-smooth;
module SSR renders — SSR/SPA consistent), no reload;
Cache-Control: no-store. TodaysendLoaderResponseapplies the loader's
maxAgeto every envelope, and HTTP 200 is heuristically cacheable withno headers at all (RFC 9111) — a CDN could pin one user's error for everyone (the SvelteKit+
Vercel "SvelteSpill" incident is the real-world instance). Success envelopes keep the existing
cache policy.
fetchCache(per-tab, 5s TTL) caches{e}envelopeslike any response;
invalidate(true)bypasses it — kept as-is.blockSSR: falseloaders (experimental) cannot abort (stream started): a thrown failcarrier reaches the signal, is not a
PublicError→ unexpected → boundary + dev warn.error page (the existing user-response 500 path).
5.2 Async computeds
Same core semantics as loaders (they share the signal). No
fail()in scope — a client-capablecompute cannot abort; its options are
PublicError(inline) or unexpected → boundary.5.3 server$
An imperative RPC (awaited like fetch), not a document render — so no
fail(), no error.tsx:PublicError→ the response is a 200 envelope (distinguishable from a successbody by shape) carrying the serialized error, with
Cache-Control: no-store; the clientawait serverFn()rejects with the inflatedPublicError(instanceofholds). Rationale:no invented status (the class has none — deliberately), consistency with the loader.json
envelope, and classification by body shape rather than headers/status (SolidStart's
header-keyed 200 protocol let infra 500s resolve as success for years). Expected errors are
business outcomes, not transport failures; unexpected ones keep a real 500.
plain 500.
try/catcharound the call handles the rejection; an UNHANDLED rejection bubbles viathe unhandledrejection→qerror bridge to the closest
ErrorBoundaryaround the callingcomponent/element.
generic) keys on "this is a server-function request", never on the HTTP method — so a future
transport (e.g. RFC 10008
QUERYfor cacheable, safe, body-carrying reads) is a pure additionto the method switch and touches zero error paths.
5.4 Actions
.pending— in-flight until the request returns (was.isRunning)..error— a thrownPublicError, LOUD and identical to loaders:.valuere-throws it on anunguarded read; guard via early return. No per-surface special-casing.
.formErrors/.fieldErrors— SOFT, value-channel, never gate.value: produced by thevalidator (
zod$/valibot$/custom) or manually via a value-returning helper — working nameinvalid({ formErrors, fieldErrors }), the successor to v1'sreturn fail(status, {formErrors, fieldErrors}). Value-channel is what keeps them staticallytypeable from the validator/action signature (thrown values cannot contribute to derived
types — the reason SvelteKit dev-enforces return-only for its form failures).
.submit— the trigger.throw new PublicError()for a genuine must-handle error;validation / "didn't go through, keep the form" → soft channels.
.errorsuppresses loader revalidation(React Router uses 2xx-vs-4xx as exactly that switch).
5.5 Middleware / endpoints
throw fail(status, data)— the blessed abort (via theRequestEventCommonpromotion);throw error()deprecated, unchanged behavior.PublicErrorthrown in middleware is UNEXPECTED (redacted generic 500) plus a dev-modewarning: "PublicError thrown in middleware has no reader — use fail(status, data) to abort with
an error page, or throw it from a loader." Middleware has no signal to read the error back from,
and honoring it would mean silently inventing a status and an abort the developer didn't write.
Fail-closed, symmetric with the blockSSR:false rule.
6. Status & caching rules
throw fail(s)(loader or middleware, pre-stream)s{d}maxAge/eTag policy{e}/{r}no-storeno-storeno-storePublicErrorno-storeno-storeGrounding: HTTP 200 is heuristically cacheable by default under RFC 9111 (500/502/503 are not),
so every 200-with-error-payload path must fail closed with explicit
no-store. On the SEO side,Google's soft-404 line matches the model exactly: a 200 page with an inline error notice on real
content is fine (out-of-stock is the canonical example); a 200 page whose content is the error
gets dropped from the index — which is what
fail(404)is for. Docs must state this as a hardrule: not-found main content →
fail(404);PublicErroris for component-level states.(Nuxt's opt-in status is the cautionary tale — years of shipped soft-404s.)
The risk class here is mis-selection, not omission: unlike Nuxt (where doing nothing ships the
soft-404 — the error ref auto-populates silently), Qwik has no zero-effort path — expressing an
expected failure requires an explicit throw either way, both options are equally cheap one-liners
in the same loader signature, and an unguarded inline error throws to a boundary instead of
rendering silently. Mis-selection is teachable where omission isn't — which is why the load-bearing
mitigation is placement, not prose: the not-found example in the starters and the first
routeLoader$docs example must be thefail(404, …)form, with the widgetPublicErrorexamplebeside it.
7. Security model
PublicErrordata ever serializes out of an error (its construction is the consent).Unexpected errors: never on
.error, never serialized (signal state serializes as invalid →client recomputes), redacted by the ErrorBoundary membrane when they render a fallback, generic
500 on transports. Stack traces: dev-only in all serialized forms.
needs to cross the wire.
PublicErrorby TypeId/class + brand;the fail carrier by module-private Symbol brand (a foreign
{failed: true}object thrown byuser code is just an unexpected error).
8. Competitive context
How each framework answers the same questions (condensed from the verified research):
query.error/.loading/.currentreturn data({errors},{status})as data.erroruseAsyncData→errorref; doc stays 200ErrorBoundaryfallback; no classthrow new PublicError→.errorguarderror(status, body)→ nearest+error.sveltethrow data(d,{status})→ nearest route ErrorBoundarynotFound()/redirect(); no arbitrary-status abortthrow createError({status})→ single rooterror.vueHttpStatusCodethrow fail(status,d)→ error.tsx chainhandleError;{message:'Internal Error'}unhandledflag hides message/data/stackdata({status}))fail()= the status path+error.svelte.datawireerror.vue; no doc statuserror()honored but static fallback pagereturn abortNavigation(err)→ error pageResponseonly; throws → platform 500throw fail(); PublicError = unexpected + dev warnreturn fail(status,data)→formprop; return-only, dev-enforced (typing)return data({errors},{status:400})→actionDataerror.datareturn {error}→submission.resultvs throw →submission.error.formErrors/.fieldErrorssoft;.errorloud{type:'error', …}status ?? 500; message-only prod$fetchrejects; real status;datapayloadX-Errorheader (proxy-500-as-success bug)PublicError→ 200 envelope +no-storePublicError= dev errorNuxtError extends H3Error,datageneric + guardPublicError<T> extends Error+ serdes TypeId + brandVerdict per dimension — "Leader" counts Qwik as designed here (theoretical direction, not shipped
code):
fail()→ error.tsx (nearest-file hierarchy, real status, SSR+SPA); SvelteKit is ahead on shipped polish and the typedApp.Errorpayload — closable with a typed fail-data interface (App.Error-style augmentation), which is not part of this design.handleErrorhook); this design has no equivalent global hook.formprop, enforced verbs, progressive enhancement). The §5.4 model matches it and adds the loud.errorchannel SvelteKit lacks.no-store, zero leak surface) but deliberately minimal; a tRPC-stylecodefield is a possible extension, not part of this design.instanceofsurvives resume (own TypeId + brand). RR can't rehydrate custom classes, Solid collapses subclasses, SvelteKit hides its classes — nobody else can offer this.Overall: Qwik (as designed) > SvelteKit > React Router > Nuxt > SolidStart > TanStack Start — the
differentiator being that all three channels are first-class and symmetric across every surface,
while each competitor is missing at least one channel outright. The §6 soft-404 placement rule is
load-bearing for the status row.
What the survey validates: the readable
.error/.pending/.valuetriple is where the ecosystemconverged (TanStack Query, SWR, Nuxt refs, Next.js "model expected errors as return values", and
SvelteKit's newest API — remote-function
query.error/.loading/.current— is the same triple);class-based safe-by-contract classification is universal; soft validation on the value channel is
unanimous; keeping route-level error pages (error.tsx) and component boundaries (ErrorBoundary)
as distinct mechanisms matches the strongest designs. Qwik is differentiated by having all three
channels as first-class, symmetric APIs — every other framework is missing at least one (see the
first column of gaps) or leaks in prod.
9. Compatibility & migration
return fail(s, d)→.value.failedthrow new PublicError(data)→.errorguard;return fail()deprecatedPublicErroronlythrow error(status, data)(also undocumentedthrow fail())throw fail(status, data);throw error()deprecated shimthrow fail()onlyErrorBoundary(never route-scoped — the framework can't know main-content-vs-widget; main content escalates viatry/catch→throw fail())PublicError(core class).errorread channel.pending.loading(and action.isRunning)ErrorBoundaryfail()RequestEventCommon; throw = abort (blessed); return =.value.failed(deprecated)error.tsxfail()-triggered; renders on SSR and SPA naverror()ServerErrorthrow fail(status, data)Deprecation-message coherence:
error()→ "throw fail(status, data)";fail()return-form /.value.failed→ "throw new PublicError(data)". Release impact:@qwik.dev/coreminor(PublicError + read semantics + rename);
@qwik.dev/routermajor (ServerError removal + failpromotion + abort recognition).
Docs must carry, verbatim-level: the one-sentence model ("throw new PublicError(data) for
user-friendly errors anywhere →
.erroror closest ErrorBoundary; throw fail(status, data) in aloader/middleware to abort the page with a status + error.tsx"), the soft-404 rule (§6), and the
try/catch trap ("don't wrap
throw fail()in your own try/catch — a swallowed abort renders aconfusing success"; same for accidentally catching
PublicErrorbefore the framework sees it).Test specs (no bodies)
A. Core unit —
async-signal.unit.tsx.errorread triggers computation (no prior reads).errorread while computing throws the pending compute promise.errorread while computing returns undefined; error effects fire on settle.errorreturns it;.valueread THROWS it;.pendingfalse(pin:
.loadinggone,.pendingpresent).errorundefined;.valueread throws it → boundarychannel; returned plain Error stays a value
.valuesetter clears inline AND unexpected state.error.errorisPublicError | undefined(package-duplication guard)
B. Core spec —
use-computed.spec.tsx(SSR harness + resume).error-guarded component renders the friendly branch on PublicError.value+ PublicError + boundary → closest fallback renders.value+ unexpected + boundary → fallback renders (existing, keep).valuethrows → boundaryfallback (not the friendly branch)
<p>{sig.value.x}</p>) errors → boundary via theMaybeAsyncSignal path
.errorreturns PublicError (instanceof holds).error) + dev warnC. Serdes —
serdes.unit.ts.errorreadable after inflateunexpected message text absent from serialized output (leak pin)
.error(classification integrity — negative pin)
.value.failed === true+ data keys(
return fail()compat after resume)D. Router unit — loaders middleware / route-loaders / server$
error.tsx re-render after an abort)
.value.failed+ response status set, page renders,.errorundefined(deprecated pin)
{e}envelope HTTP 200 (instanceof after deserialize);abort throw → real status response; unexpected → 500 without error detail
the error render re-runs neither route middleware nor the crashed loader
failon RequestEventCommon but NOT a free export and NOT on component/clienttypes;
error()@deprecated returning the fail carrier;ServerErrorimport is a compileerror;
PublicErrorexported from corethrow fail(status, d)→ abort: status + error page (blessed pin)throw error(status, d)(deprecated shim) → still abortsreturn fail()→.value.failed(v1 compat).return error()→ inert value(v1 no-op) — unsupported, not a pin
throw fail(status, d)→ abort (via the Common promotion).value.failed), never abort (carrier stays out ofthrowIfControlFlowSignal)throw new PublicError(d)→ 200 error envelope +no-store; client await rejectswith the inflated instance; unhandled → closest ErrorBoundary (qerror bridge); try/catch
handles it. Unexpected throw → 500 generic → boundary
{message}(no char-spread); carrier embedsstatus; getErrorHtml + serverErrorMiddleware read.messagenot.dataev.fail()(check()hazard pin); still renders the 404 page
instanceof Error, has a stack,.value.failed+spread-data reads unchanged (compat pin)
{failed: true}object (no brand) → UNEXPECTED, not an abort (spoof pin)Cache-Control: no-store(success keeps theloader cache policy); server$ error responses send
no-storeE. Router e2e (qwikrouter-test app)
.errorbranch, HTTP 200(not
.error).errorbranch renders client-side (envelope +instanceof); the
{e}response carriesno-store(redaction pin)
pin:
return error(401)no longer aborts — the fixture becomesthrow fail(401, {message}))throw fail()s → client renders the route's already-resolvederror.tsx chain (same module SSR renders), passing the fail status/message; no reload.
Direct load / refresh → SSR error.tsx with the real status
by existing e2e, stays green)
(chain-resolution pin)
F. Actions (the §5.4 surface)
.errorset; unguarded.valueread re-throws → closestboundary (identical gating to loaders)
.errorguard renders the inline branch; the form stays rendered and resubmittable.fieldErrors/.formErrorspopulated;.valueNOT gated;.errorundefined (soft channels never gate)return invalid({ formErrors, fieldErrors })→ soft channels populated, staticallytyped from the action signature
.submit()rejects; unhandled → closest boundary; redacted in prod.pendingtrue while submitting, false after settle (pin:.isRunninggone)return fail()→.value.failedcompat, never abort (pairs with D16)All reactions