Network Failures Only the Client Can See
Requests that never arrived, timeouts, DNS and TLS failures, opaque CORS errors, offline users and navigations that cancel in flight — the failures with a zero server error rate.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
The server reports a clean error rate and users say the app is broken — where is the failure happening?
Someone taps Save on a train. The spinner runs, then stops, and the row is not there. Nothing anywhere in the system recorded that this person failed, because the request never reached anything that logs.
The backend has metrics and structured logs on every endpoint. If requests were failing, the error rate would show it, so a clean dashboard means the API is healthy.
A request that never reached the server cannot appear in the server's logs. DNS failure, connection failure, TLS failure, a captive portal, a proxy that dropped it — the server's view of all of these is identical to the view of a request nobody made.
- A request that never reached the server cannot appear in the server's logs. DNS failure, connection failure, TLS failure, a captive portal, a proxy that dropped it — the server's view of all of these is identical to the view of a request nobody made.
- A request that the client timed out on may have been served perfectly. The server logs a success; the user saw a failure; both records are accurate.
- A blocked cross-origin response is a successful request the server logged and the browser then refused to hand to your code. The status was fine and your code got a rejection (CORS).
- An offline user generates no traffic at all. From the backend, an outage in one region and everyone in that region having a bad connection look the same.
- A navigation cancels in-flight requests. Those show as client aborts, and counting them as failures makes the dashboard useless while counting them as successes hides real ones (Cancelling a Request Nobody Is Waiting For).
- An ad blocker or corporate proxy that removes one script means your code never runs at all. The server saw a normal page request and nothing else.
What is actually happening
In the browser, not in the framework.
- A browser request passes through several stages before anything server-side exists to observe it: resolution, connection, TLS, request transmission, response, and then the browser's own policy checks on the response. Failure at any stage before the last two is invisible server-side (The Lifecycle of One HTTP Request in Networking).
fetch()rejects only for network-level failures. A response with an error status is a fulfilled promise withok: false; a DNS failure, a refused connection, a TLS failure, a CORS block and an offline device all reject with aTypeErrorcarrying almost no detail.- That opacity is deliberate. Distinguishing "host does not exist" from "host exists and refused" from "response was blocked by policy" would let any page probe the user's network and the internal hosts reachable from it, so the browser collapses them into one indistinguishable rejection (The Same-Origin Policy).
- The detail does exist — in the devtools console, which the browser writes to directly. Script cannot read it. This is why a CORS problem is trivially diagnosable by a developer looking at the console and completely undiagnosable from telemetry.
AbortErroris different in kind: it means your code, or a navigation, cancelled the request. It is not a failure of anything and must be classified separately or it will drown the real signal.- Client timeouts are a decision, not an event. There is no built-in
fetchtimeout; a timeout exists only because you created one withAbortSignal.timeoutor an equivalent, and its value determines how many slow-but-successful responses get recorded as failures (Timeouts: The Latency Contract Nobody Writes Down in Observability & Performance). navigator.onLinereports whether the device has a network interface with a route, not whether anything is reachable.trueon a captive portal is the normal case, not an edge case.
What this makes the browser do
And which of it is avoidable.
- The browser retries some things for you at a layer below
fetch— connection reuse, HTTP/2 stream recovery, some connection-level retries — so a singlefetchmay correspond to several attempts you never see. - A preflight request for a non-simple cross-origin call is an extra round trip before your request goes anywhere, and a preflight failure fails your request without your request ever having been sent (CORS).
- Requests in flight when a navigation starts are cancelled by the browser. This is correct behaviour and a large fraction of what naive instrumentation records as errors.
- A service worker sits between your
fetchand the network, so a failure may be its cache logic rather than the network at all (Intercepting Fetch).
Where a request can die without the server noticing
Draw the request path and mark the point at which the server first has a record. Everything to the left of that mark is a failure class with a zero server-side error rate, and it is where a surprising amount of real-world breakage lives.
The last stage is the one that catches people out. A cross-origin response that fails the browser's access check was fetched successfully and logged as a success — the browser simply declines to hand it to your code. Server-side, that is a healthy request. Client-side, it is a total failure.
The failure table the backend cannot produce
Each row is a distinct cause with a distinct fix, and every one of them is either invisible or misleading from the server side. The value of the table is that it turns "the app is broken for some people" into a set of hypotheses you can instrument for.
Notice how many rows end in the same client-visible symptom — a rejected promise with almost no detail. That collapsing is the browser's doing and it is not going to change, so the client instrumentation has to recover the distinction from context: whether other requests to the same origin succeed, whether the device believes it is online, whether a preflight preceded it, whether a navigation was in progress.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| DNS resolution fails | fetch rejects with a bare TypeError | Name did not resolve — bad configuration, split-horizon DNS, or a filtering resolver | Record as a network-level rejection; correlate by origin, since all requests to that host fail together (DNS Failure Modes: What Each One Looks Like in Networking). Server side: no record at all. |
| Connection refused or TLS handshake fails | Same bare TypeError, sometimes after a noticeable pause | Port closed, certificate rejected, or a middlebox intercepting TLS | Distinguish by whether any request to the origin has ever succeeded this session (The TLS Handshake in Networking). Server side: nothing, or a handshake error in a log nobody joins to a user. |
| Cross-origin response blocked by the browser | Rejected promise; a detailed explanation in the console that script cannot read | Response lacked the headers the browser requires to expose it to this origin, or a preflight was not answered | Detect by pattern — one origin failing while same-origin requests succeed — and read the console once, by hand (CORS). Server side: a logged success. |
| Client timeout fires | An AbortError from your own AbortSignal.timeout | Response slower than a limit you chose; the request may well complete afterwards | Record separately from network failures, with the limit that fired, and never retry a write without an idempotency key. Server side: a slow success. |
| Navigation cancels in-flight requests | AbortError, often several at once | The browser cancels pending requests for a document that is going away | Classify as user-cancelled, exclude from failure rates, and count it — a rise means people are leaving mid-load (Cancelling a Request Nobody Is Waiting For). |
| Device is offline | Immediate rejection; no request issued | No usable network path | Queue the intent, tell the user plainly, and flush when connectivity returns (The Offline Mutation Queue). Server side: silence indistinguishable from nobody trying. |
| Captive portal or intercepting proxy | A response arrives, but it is an HTML login page where JSON was expected | The network answered on the server's behalf | Validate the content type before parsing; a parse error on a successful status is a strong signal for this. Server side: nothing. |
| Extension or blocker cancels the request | Rejection for one specific host only, correlated with the user, not the route | A blocklist matched your host — commonly an analytics or telemetry endpoint (Third-Party Scripts and the Supply Chain) | Serve first-party paths for anything you must not lose, and treat telemetry volume as a lower bound. |
Classifying the outcome instead of counting errors
One wrapper around the request layer is all this needs, and its whole value is in refusing to collapse categories. "Failed" is not a classification; it is the absence of one.
The wrapper also has to be honest about what it cannot know. When a TypeError arrives there is genuinely no way, from script, to tell a DNS failure from a policy block. Recording it as network-rejected with the surrounding context — was the device online, had any request to this origin succeeded, was a navigation in progress — is a truthful record that supports later analysis. Guessing a specific cause and writing it down as fact is how a dashboard becomes confidently wrong.
1type Outcome =2 | 'ok' // 2xx, response handed to us3 | 'http-error' // response arrived with an error status4 | 'network-rejected' // TypeError: DNS, connection, TLS, policy block, offline5 | 'timeout' // our own limit fired6 | 'cancelled' // navigation or our own abort7 | 'bad-content' // status said ok, body was not what the contract promises8 9async function instrumented(input: string, init: RequestInit & { limitMs: number }) {10 const started = performance.now() // monotonic; wall clock is not11 const signal = AbortSignal.any([12 init.signal ?? new AbortController().signal,13 AbortSignal.timeout(init.limitMs),14 ])15 16 let outcome: Outcome17 try {18 const res = await fetch(input, { ...init, signal })19 if (!res.ok) outcome = 'http-error'20 else if (!res.headers.get('content-type')?.includes('json')) outcome = 'bad-content'21 else outcome = 'ok'22 return res23 } catch (e) {24 // The browser will not tell script WHY. Record the class plus context and25 // let analysis separate the causes; do not invent one here.26 outcome = e instanceof DOMException && e.name === 'AbortError'27 ? (signal.reason === 'timeout' ? 'timeout' : 'cancelled')28 : 'network-rejected'29 throw e30 } finally {31 report({32 route: routePatternFor(input), // never the filled-in URL33 outcome: outcome!,34 clientMs: performance.now() - started,35 onlineHint: navigator.onLine, // a hint about an interface, not a fact36 originHadSuccess: originHealth(input),37 navigating: navigationInProgress,38 requestId: init.headers?.['x-request-id'], // joins to the server record39 })40 }41}The finally block reports successes too — a failure rate needs a denominator, and only the client has one. The request id is what makes "the client saw a failure and the server has no record of it" a provable statement rather than a suspicion.
How to build it
Most important first.
- Instrument at the client. Wrap the request layer once, and record outcome class, duration, route pattern, release, and connection class for every request — successes included, since a failure rate needs a denominator that only the client has.
- Classify outcomes into categories that map to different actions: HTTP error status, network-level rejection, timeout, abort by navigation, abort by your own code, blocked by policy, offline. Merging any two of these makes the resulting number unactionable.
- Send a correlation identifier with every request and record it on both sides, so a client-observed failure can be checked against a server record — including the case where there is no server record, which is itself the finding (Correlation IDs: Turning Lines Into a Story in Observability & Performance).
- Record client-side duration for successes too. The difference between server-observed and client-observed duration is the network, the queue and the parse, and it is invisible from either side alone.
- Buffer failure reports and flush them when connectivity returns, otherwise your offline failures are precisely the ones that cannot be reported (The Offline Mutation Queue).
- Detect connectivity by attempting a request, not by trusting
navigator.onLine. Use the flag as a hint to stop retrying, never as evidence that the network works. - Show the user something honest and recoverable. A failure they can retry is a different product from a spinner that never resolves (Loading, Error, Empty — The States You Did Not Render).
- Retry only what is safe to retry, with backoff and jitter, and never retry a non-idempotent request without an idempotency key (Retries, and the Duplicate Order).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A failure that only exists as a spinner is invisible to a screen-reader user, who has no way to distinguish "still loading" from "stopped forever". Failure states must be announced, and a live region or a focus move is the mechanism (Live Regions and Announcement).
- Retry must be reachable by keyboard, and focus must land somewhere sensible when the error replaces the content the user was on. Replacing a region without moving focus strands a keyboard user in a container that no longer exists (Focus Management).
- Do not use colour alone to indicate a failed state, and do not remove the failed control from the tab order — a disabled retry button that stays disabled is a dead end with no explanation (Errors People Can Actually Perceive).
- The instrumentation wrapper must be transparent. A
fetchwrapper that swallows an abort, or an event wrapper that changes what a handler returns, can break a form submission or a keyboard-activated control while being invisible in every test that uses a mouse.
What can go wrong
- Counting aborts as errors: every navigation inflates the failure rate and the metric becomes noise that nobody alerts on.
- Counting aborts as successes: real cancellations caused by a crash or a hung request disappear.
- A client timeout tighter than the server's slowest legitimate response, converting a slow endpoint into a fabricated error rate — and, for writes, into duplicate submissions.
- Reporting the failure over the same network that just failed. The report is lost exactly when it matters most, unless it is persisted and retried later.
- Trusting
navigator.onLine, and telling a user on a captive portal that everything is fine while nothing works. - Treating an opaque
TypeErroras a single cause. It is at least five different causes, and your dashboard will show one bar that means nothing. - Retrying a request that already succeeded but timed out client-side, creating the duplicate the user later reports as a bug (Idempotency Keys: The Mechanism in API Design).
- A request can succeed on the server after the client has already timed out, so the user sees a failure for an operation that actually happened — the classic source of duplicate submissions.
- A navigation cancels in-flight requests at an arbitrary point. Some had already been processed server-side; some had not; the client cannot tell which.
- Responses arrive out of order, so a slow earlier request can resolve after a faster later one and overwrite fresher state (Out-of-Order Responses).
- A retry and the original can both be in flight. Without an idempotency key the server sees two independent requests, because it has no way to know they were the same intent.
- Connectivity can return mid-flush, so a queued failure report and a fresh request race, and the queue must tolerate duplicates on its own ingest path.
- The browser withholds the reason for a cross-origin failure from script on purpose. Without that, any page could use fetch failures to map a user's internal network — which host names resolve, which ports answer — from inside the browser (Server-Side Request Forgery (SSRF) in Security Engineering).
- Client-reported failure data is attacker-controllable. Anyone can post fabricated network failures to your ingest endpoint; treat the data as evidence, never as an authorization or rate-limiting input.
- Failure reports carry URLs, and URLs carry tokens and identifiers. Report route patterns and strip query strings before sending (URL Parameters).
- A retry loop with no cap is a self-inflicted denial of service that starts on your users' devices and lands on your own infrastructure, and it gets worse exactly when your service is already degraded (Retry Storms: The Load You Generated Yourself in Observability & Performance).
- An offline queue holding pending mutations is a store of user data on the device, with the retention and exposure questions that implies (Storage Security and Durability).
- "The server error rate is zero, so nothing is failing." A zero error rate is equally consistent with nothing failing and with requests never arriving. The client is the only witness that can tell those apart.
- "
fetchrejected, so the server is down." It rejected because something between your code and a response went wrong. That includes DNS, the connection, TLS, a policy block, an extension, a service worker and the device being offline. - "CORS blocked my request, so the request was not sent." Usually it was sent and answered; the browser then refused to expose the response to your script. The server has a log line for it.
- "
navigator.onLineis false, so we are offline." It is a hint about a network interface.trueon a captive portal is normal, and code that trusts it will tell users everything is fine while nothing works. - "Aborts are errors." Most of them are a user navigating away, which is the browser doing exactly the right thing.
- "We can retry our way out of this." Retries convert one user's bad connection into more load on a service that may already be the reason for the failure.
Measuring it, and what changes in the field
- A client-observed outcome breakdown per route pattern: success, HTTP error, network rejection, timeout, abort. This is the metric the server structurally cannot produce.
- Client-observed request rate against server-observed request rate for the same route. A persistent gap is requests dying before they arrive, and its size is the number nobody has.
- The devtools Network panel for a single case: the failure reason, the timing breakdown, and whether the request was even issued (Debugging the Network).
- The console for the specific cross-origin diagnosis, which is the only place the browser explains a policy block (CORS).
- The difference between client-observed and server-observed duration for successful requests, which is where the network and the queue live (Reading a Network Waterfall).
- On a mobile network, transient failures are routine rather than exceptional: handovers, dead zones and lossy links produce failures that no server-side change can fix and that only client instrumentation can size (Packet Loss: Duplicate ACKs, Fast Retransmit and the RTO in Networking).
- On a corporate network, proxies and inspection appliances rewrite requests, break TLS, and block hosts. Failures are concentrated in one customer and invisible everywhere else.
- On a slow device, requests can queue behind main-thread work and time out without the network being slow at all (Long Tasks).
- In a long-lived tab, an expired session or a removed endpoint turns previously working requests into consistent failures for a subset of clients running an old bundle (Long-Lived Clients and Version Skew).
- While offline, everything fails and nothing reports until connectivity returns — so the offline story must be written before the data exists to motivate it (Offline UX).
- Client-side instrumentation is the only source of this signal and it is unreliable exactly where it matters, because reporting a network failure requires the network. Persistence and later flushing buy fidelity at the cost of storing data on the device.
- Fine-grained outcome classification makes the dashboard actionable and makes the instrumentation code something you now have to maintain and keep transparent.
- Tighter client timeouts surface hangs sooner and manufacture failures out of slow successes. There is no value that is right for every endpoint.
- Retries improve the success rate a user experiences and multiply load during an incident. The mitigation has a failure mode of its own, and it is a big one.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThat
fetch()rejects with a low-detailTypeErrorfor network-level failures, resolves withok: falsefor error statuses, and rejects withAbortErroron cancellation is specified and behaves identically in Chromium, Gecko and WebKit; only the human-readable message text attached to the rejection differs between them. - BROWSER-SPECIFICThe console diagnostics differ substantially: Chromium prints a specific reason for a blocked cross-origin response and names the missing header, Firefox prints a shorter reason with its own vocabulary, and Safari is the least detailed of the three — so a debugging instruction written against one browser's message text will not match what a colleague sees.
- NETWORK-SPECIFICFailure mix is dominated by the access network: mobile links produce transient timeouts and mid-request handovers, corporate networks produce TLS interception and blocked hosts, and consumer broadband produces comparatively few of either — so a single global failure rate averages populations with entirely different causes and fixes.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a client that cannot tell "did not happen" from "happened and the acknowledgement was lost" is the classic partial-failure problem, seen from the browser's side of the partition.