diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md index 2933ee5..9ae5aa6 100644 --- a/docs/troubleshooting/index.md +++ b/docs/troubleshooting/index.md @@ -92,5 +92,6 @@ or just "now I know to look here first." ## Entries - [Browser Keeps Prompting to Log In on Every Article (polyfill.io)](polyfill-io-login-prompt.md) +- [Intermittent "CORS Error" / "Failed to Fetch" That's Actually a Stale Browser Cache](stale-browser-cache-fetch-cors-error.md) - [WiFi Bufferbloat Collapses the Connection When Streaming to a LAN Device (Expo, Steam Link)](wifi-bufferbloat-lan-streaming-drops.md) - [WiFi Card Drops Connection Under Load Due to PCIe Power Management (mt7925e Forced Reassociation)](wifi-card-pcie-power-reassociation-drops.md) diff --git a/docs/troubleshooting/stale-browser-cache-fetch-cors-error.md b/docs/troubleshooting/stale-browser-cache-fetch-cors-error.md new file mode 100644 index 0000000..4d03c60 --- /dev/null +++ b/docs/troubleshooting/stale-browser-cache-fetch-cors-error.md @@ -0,0 +1,144 @@ +--- +tags: + +- troubleshooting +- javascript +- networking +- browser +- caching + +--- + +# Intermittent "CORS Error" / "Failed to Fetch" That's Actually a Stale Browser Cache + +An intermittent `TypeError: Failed to fetch` with a "blocked by CORS policy" console +message turned out to have nothing to do with CORS, the server, or the network — the +browser's HTTP cache was replaying a stale error response for every request to that exact +URL, until the entry expired. + +## Environment + +- Symptom: `fetch()` HEAD probe to a cross-origin S3 URL, from a read-aloud audio player +- Browsers: desktop Chrome, incognito, mobile Samsung Internet — all reproduced it +- Deployment target: static export (GitHub Pages), not a live dev/prod server + +## Problem + +- A `fetch()` HEAD probe to a cross-origin URL intermittently failed with + `TypeError: Failed to fetch`, and the console showed: + + ```text + Access to fetch at '...' has been blocked by CORS policy: No + 'Access-Control-Allow-Origin' header is present on the requested resource. + ``` + +- Worked once, then failed reliably for 10-20 minutes, then worked again — reproducible + across desktop Chrome, incognito, and mobile Samsung Internet. +- `curl` from multiple machines/networks, with matching headers, HTTP/2, concurrent + bursts — never reproduced it, not once across 100+ requests. + +The curl-vs-browser split was the first real clue: whatever was wrong lived somewhere +`curl` doesn't have — a warm browser cache. + +## Root Cause + +**The browser's HTTP cache stores an error response (e.g. a 503) for a `fetch()` call and +keeps replaying it** for every subsequent request to that exact URL+method under default +cache mode — for minutes, until the entry is evicted. `curl` never hits this because it +never has a warm browser cache to poison in the first place. + +A "blocked by CORS policy: no Access-Control-Allow-Origin header" message is **not proof +of a CORS misconfiguration** — it's what the browser shows for *any* cross-origin failure +where the response (real or cached) lacks CORS headers, including a stale cached error. + +```mermaid +sequenceDiagram + participant App as App fetch() + participant Cache as Browser HTTP Cache + participant Server as Origin Server + + App->>Server: fetch(url) — first request + Server-->>App: 503 (transient) + Note over Cache: Error response cached
under default cache mode + App->>Cache: fetch(url) — retry, minutes later + Cache-->>App: Replays stale 503 (no CORS headers) + Note over App: Browser reports it as a
CORS error, not a cache hit + App->>Server: fetch(url, {cache: "no-store"}) + Server-->>App: 200 (server was fine all along) +``` + +### How it was proved (the reusable part) + +1. Caught the app's probe failing live via DevTools network inspection. +1. **In the same tab, at the same moment**, ran a manual `fetch()` to the *exact same URL* + from the console: + - Same options as the app's fetch (default cache mode) → failed, every time. + - Same call but with `cache: "no-store"` added → succeeded, every time. +1. Same URL, same tab, same instant — the only variable was cache mode. That's about as + close to a controlled experiment as you get in a live browser bug. +1. Independently confirmed via Chrome DevTools' **"Disable cache"** checkbox (Network + tab) — failures stopped completely while checked. + +## Fix + +Add `cache: "no-store"` to any `fetch()` that's a **status/availability probe** — +something that must always reflect current server state (HEAD checks, health checks, +"does this resource exist yet" checks, short-lived metadata): + +```javascript +const res = await fetch(url, { method: "HEAD", cache: "no-store" }); +``` + +Cache the *content* (via normal `Cache-Control` headers on the actual resource), never +cache the *probe*. + +## Prevention + +```mermaid +flowchart TD + A[Intermittent fetch failure,
CORS-looking console error] --> B{curl reproduces it?} + B -->|Yes, consistently| C[Real server/CORS/network issue] + B -->|No, never| D[Toggle DevTools 'Disable cache'] + D -->|Failures stop| E[Stale cached error response] + D -->|Still fails| F[Look elsewhere: proxy, extension, real CORS] + E --> G["Add cache: 'no-store' to the probe fetch"] +``` + +- **If `curl`/server-side tools can't reproduce a client-reported network failure no + matter how hard you try, stop trying to reproduce it server-side** — the bug is + client-side. +- Verify the server's actual live headers with `curl -I` before touching CORS config — + don't trust a "CORS" console message at face value. +- Toggle DevTools "Disable cache" before writing any code — it's a 2-second test for "is + this a caching problem." +- A/B test the *exact* failing request, in the *exact* failing session, changing one + variable at a time (cache mode, headers, protocol) via the page's own console — this + beats theorizing every time. +- Don't declare victory on "no console errors" from a single local test — verify against + the real target environment, and re-run the *original* repro steps, not just a proxy + check. +- Add `cache: "no-store"` by default to any fetch that's a status/availability probe, not + just after you've already been burned by this once. + +## Compounding Bugs That Made This Harder to Diagnose + +Several separate, real bugs were compounding into similar-looking symptoms during the +same investigation, which made it easy to mistake a partial fix for *the* fix: + +- **A genuine Next.js 14.1.x bug**: under a non-empty `basePath` + static export, the App + Router's client-side RSC/flight-payload prefetch computed a malformed URL for the root + route, 404ing on every page load and cascading into a full fallback page reload. Fixed + by upgrading to 14.2.x. +- **`trailingSlash: true` changed the symptom without fixing the bug** — the malformed + URL changed shape but was still wrong. Don't assume a config change "fixed" something + just because the exact error string changed; re-verify against the *original* repro. +- **A missing retry path**: the component's own probe had a "probe once, ever" guard — + if the first probe failed for any reason (including the cache issue above), it was + stuck showing the error forever with no recovery short of a full remount. Worth fixing + independently, but retrying alone would've just kept re-hitting the same poisoned cache + entry. +- **A misleading local test environment**: `next dev`/`next start` (a live server) can't + reproduce bugs specific to a real static-export deployment (no dynamic RSC endpoint, no + image optimization endpoint). A Docker Compose + nginx replica of the real deployment + shape catches that class of bug — though even that replica can't catch the cache bug, + since browser HTTP caching is a client-side concern no server replica reproduces.