#8882·qwik

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}
  • )}
); ``` Task-side observation (toast, no content replacement): ```tsx useTask$(({ track }) => { if (track(() => orders.error) instanceof NetworkError) { toasts.push('Connection lost — data may be stale'); } }); ``` `server$` — imperative, the caller decides: ```tsx 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`; `AbortError` excluded; HTTP responses excluded; prefetch failure is silent; coalesced rejection fans out once. - E2E: offline SPA nav → boundary shows the `instanceof NetworkError` branch; offline background refresh → retained value + `.error` badge, boundary never fires; offline `server$` call → `try/catch` receives an `instanceof 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.