feat: typed NetworkError for branchable connectivity failures (ErrorBoundary follow-up)
Author: maiieulCreated Jul 28, 2026Updated Aug 4, 2026
LabelsV2
> [!NOTE]
> Updated 2026-08-04 to match the converged v2 error-API design: `PublicError` is 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 `` (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`
```ts
// @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.
```ts
// 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
1. **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).
2. **Wrap scope**: only framework-owned, transport-level rejections (offline, DNS, CORS-opaque, timeout). `AbortError` stays cancellation. Any HTTP response — error envelopes, 422, 500-no-detail — keeps its existing path.
3. **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.)
4. **Semantics = client connectivity only**: `NetworkError` means "*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 to `throw httpError(503, …)` or a typed return. Framework-constructed instances are client-side only and never cross the wire, so `instanceof NetworkError` just works with no serializer support; a copy constructed server-side is a failure like any other throw → redacted.
5. **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).
6. **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:
```tsx
{
if (err instanceof NetworkError) {
return You appear to be offline. Retry;
}
// server failures arrive redacted — render your own copy (+ digest for support)
return
Something went wrong.
; }} > }> ``` 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: ```tsx const orders = useOrders(); // e.g. { poll: 30_000 } return ( {orders.error instanceof NetworkError && Offline — showing cached data}- {orders.value.map((o) =>
- {o.title} )}
Source: QwikDev/qwik