feat: typed NetworkError for branchable connectivity failures (ErrorBoundary follow-up)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 22.1k
- Forks
- 1.4k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 52
Description
[!NOTE]
Updated 2026-08-04 to match the converged v2 error-API design:PublicErroris removed from the model; server-origin failures are always redacted, client-origin errors never are. The original motivation ("connectivity failures get redacted") no longer applies — the surviving motivation is that transport failures need a type. The class below is now standalone.
What is it?
- Feature / enhancement — follow-up to the ErrorBoundary work in #8745 and the v2 error-model rework.
Context: the settled error model
- Server-origin failures never cross the wire. The client receives a framework-authored generic error (+ digest); the original stays server-side (logs,
onError). No exceptions — server-authored displayable content exists only as outcomes (httpError(),invalid(), redirects) or typed returns. - Client-origin errors are never redacted. Their messages come from code already in the browser, so rendering them is leak-safe by construction.
- Failures land in
.error(guarded) or the closest<ErrorBoundary>(unguarded).
Problem
A user goes offline and SPA-navigates. The loader data fetch rejects at the transport level, and that raw rejection is what lands in the failure channel. Raw is leak-safe, but it is not an offline UX:
- The message is browser-divergent trivia:
Failed to fetch(Chrome),Load failed(Safari),NetworkError when attempting to fetch resource.(Firefox). Nothing to branch on, nothing a user should read. - The useful affordance (offline notice, retry, "showing cached data") needs a type, not a string. (Raised by @wmertens.)
And the fix cannot be server-side leniency, because the redaction membrane must stay absolute. Unexpected err.message values in production routinely name infrastructure — and they fire exactly during incidents (credential rotation, network partition) when no app code changed:
| Source | Real production err.message |
|---|---|
pg (auth failure, 28P01) |
password authentication failed for user "admin" |
| Node net layer | connect ECONNREFUSED 10.0.3.7:5432 |
| Node DNS | getaddrinfo ENOTFOUND db.internal.corp |
mysql2 |
Access denied for user 'admin'@'10.0.2.14' (using password: YES) |
| Prisma P1000 | Authentication failed against database server at `10.0.3.7`, the provided database credentials for `admin` are not valid |
| Prisma P1001 | Can't reach database server at `10.0.3.7`:`5432 |
| AWS SDK (IAM) | User: arn:aws:iam::123456789012:user/app-server is not authorized to perform: s3:GetObject on resource: … |
| undici / Node 18+ fetch | message is fetch failed, but err.cause carries connect ECONNREFUSED 10.0.3.7:443 |
Connectivity failures were never "unexpected" in the first place: the framework can prove what they are, because the framework owns the fetch. No inference, no consent problem.
Proposal: a framework-constructed NetworkError
// @qwik.dev/core — vocabulary, not machinery; usable router-less
export class NetworkError extends Error {
constructor() {
super('Could not reach the server'); // framework-authored message
}
}
Standalone class — no parent, no serdes, no special cases. It rides the ordinary failure channels (.error / boundary); instanceof is the entire API.
// router fetch layer (loader data fetches, server$ client stub) — sketch
try {
response = await fetch(url, { signal, headers });
} catch (e) {
if ((e as Error)?.name === 'AbortError') throw e; // cancellation stays cancellation
throw new NetworkError(); // transport-level rejection ONLY
}
// an HTTP response is NOT a network error — the server answered; those paths are unchanged
Rules
- Placement: class in core (re-exported from the router for discoverability); wrap sites in the router (loader data fetch layer,
server$client stub, and the batched transport when/if it lands). - Wrap scope: only framework-owned, transport-level rejections (offline, DNS, CORS-opaque, timeout).
AbortErrorstays cancellation. Any HTTP response — error envelopes, 422, 500-no-detail — keeps its existing path. - Prefetch failures stay silent: prefetching is speculative; a failed prefetch must not construct a
NetworkError, populate.error, or touch a boundary. The nav-time fetch retries naturally. (Otherwise walking through a tunnel makes hovered links light up error UI.) - Semantics = client connectivity only:
NetworkErrormeans "this browser could not reach the server". A server-side upstream failure during SSR is a different situation (the user's connection is fine) — docs steer that tothrow httpError(503, …)or a typed return. Framework-constructed instances are client-side only and never cross the wire, soinstanceof NetworkErrorjust works with no serializer support; a copy constructed server-side is a failure like any other throw → redacted. - Fetch-layer hygiene: a rejected coalesced fetch rejects all registered consumers with the same
NetworkError, once; a transport failure teaches any transport-level caching/hints nothing (no response, no headers). - App-owned fetches (e.g. inside async computeds) get the one-line recipe: catch the transport rejection,
throw new NetworkError()— same class, same fallbacks.
What it enables
First paint while offline — the boundary displays it, and the fallback can finally branch:
<ErrorBoundary
fallback$={(err) => {
if (err instanceof NetworkError) {
return <div>You appear to be offline. <button onClick$={retry}>Retry</button></div>;
}
// server failures arrive redacted — render your own copy (+ digest for support)
return <p>Something went wrong.</p>;
}}
>
<Suspense fallback={<Skeleton />}>
<Orders />
</Suspense>
</ErrorBoundary>
Failed background refresh — with the retention semantics (a failed revalidation keeps the held value and surfaces on .error; reading .error is the guard that unlocks .value), the offline-first story falls out for free:
const orders = useOrders(); // e.g. { poll: 30_000 }
return (
<div>
{orders.error instanceof NetworkError && <Badge>Offline — showing cached data</Badge>}
<ul>{orders.value.map((o) => <li key={o.id}>{o.title}</li>)}</ul>
</div>
);
Task-side observation (toast, no content replacement):
useTask$(({ track }) => {
if (track(() => orders.error) instanceof NetworkError) {
toasts.push('Connection lost — data may be stale');
}
});
server$ — imperative, the caller decides:
try {
await save(draft.value);
} catch (err) {
if (err instanceof NetworkError) { queue.push(draft.value); return; } // offline queue
throw err; // anything else → boundary (server failures arrive redacted)
}
(Modeled server$ failures are typed returns under the settled design, so they never reach this catch.)
Tests
- Router unit: transport rejection →
NetworkError;AbortErrorexcluded; HTTP responses excluded; prefetch failure is silent; coalesced rejection fans out once. - E2E: offline SPA nav → boundary shows the
instanceof NetworkErrorbranch; offline background refresh → retained value +.errorbadge, boundary never fires; offlineserver$call →try/catchreceives aninstanceof NetworkError.
Scope
Follow-up to #8745 — not part of the EB PR. Depends on the error-model rework landing (server-side redaction membrane + .error/boundary routing). PublicError removal is tracked in the EB workstream.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by locating the core error exports and the router fetch layers for loader data, server$ client calls, and batched transport. Review the listed Router unit and E2E cases first, including AbortError, HTTP responses, prefetch, coalescing, offline navigation, refresh, and server$ behavior. Done means transport-only client failures produce NetworkError while cancellation, responses, prefetch, and server-side failures retain their specified paths.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend, testing-qa
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100