Why SPA Guard?
The problem SPA Guard solves is real, subtle, and nearly universal for any application that ships to production. This page documents the error cases, why standard tools don't cover all of them, and how SPA Guard fills the gaps.
What is a chunk load error?
Modern single-page applications use code splitting: the JavaScript bundle is divided into smaller files (chunks) that load on demand. When a user navigates to a route, the browser fetches that route's chunk — something like Home.3f8a2c.js. The hash in the filename is derived from the chunk's content, so it changes every time the file changes.
When you deploy a new version of your app, the old chunk filenames disappear and new ones appear. Any user who had the app open before the deployment still has the old HTML in their browser — with script tags pointing to the old chunk URLs. The moment they navigate to a new route, the browser tries to load a chunk that no longer exists. The server returns a 404.
The browser throws:
TypeError: Failed to fetch dynamically imported module
React's error boundary catches it. The user sees a white screen or an error message. They have no idea why.
The five error cases
Not all chunk-related errors look the same. There are five distinct cases, and each requires a different mechanism to catch.
Case 1: Chunk load error after deployment (the main case)
[User has old tab open]
→ Deploy v2 (old chunks removed from CDN)
→ User navigates to lazy route
→ import('./PageX.abc123.js') → 404
→ TypeError: Failed to fetch dynamically imported module
→ React Router ErrorBoundary may catch (if it's a lazy route)
→ OR surfaces as unhandledrejection
Solution: page reload (gets fresh index.html → new chunk references).
Case 2: Error inside a lazily-loaded module
[Chunk loaded successfully]
→ Error thrown during module initialization
→ React ErrorBoundary catches it
→ OR surfaces as unhandledrejection
Solution: React ErrorBoundary + fallback UI.
Case 3: Main chunk failure before Sentry initializes
[index.html received]
→ Browser requests main.abc123.js
→ Load fails (network, CDN, ad-blocker)
→ Sentry NOT initialized (it initializes inside the main chunk)
→ window.addEventListener('error') NOT registered
→ Error is completely lost — no one knows it happened
This is the critical case. Every other approach assumes the application's JavaScript has already started running. If the main chunk fails, nothing registered inside that chunk will ever run — including your error tracker, your event listeners, your Sentry initialization.
Solution: an inline script embedded directly in <head> before all other scripts. This script runs before any chunk is requested, so it's always active regardless of what fails later.
Case 4: CSS preload failure
[Vite loads a CSS chunk]
→ Resource blocked (network / CDN / ad-blocker)
→ Two variants:
a) DevTools block → browser silently ignores (error event does NOT fire)
b) Real 404/network error → 'error' capture phase
OR Vite throws: "Unable to preload CSS" → unhandledrejection
Solution: window.addEventListener('error', handler, true) with capture: true for HTML element errors; unhandledrejection for the Vite-specific preload error.
Case 5: CSP violation
[Browser blocks script by Content-Security-Policy]
→ Chunk doesn't load
→ 'securitypolicyviolation' event fires
→ Standard error/unhandledrejection do NOT fire
Solution: window.addEventListener('securitypolicyviolation', handler).
Browser event map
Catching all five cases requires four different event listeners. Each covers a different failure mode:
| Event | What it catches | Notes |
|---|---|---|
window.addEventListener('error', h, true) | Sync JS errors + resource load failures (<script>, <link>, <img>) | capture: true is required; bubble phase misses resource errors |
window.addEventListener('unhandledrejection', h) | Unhandled rejected promises, including dynamic import() | Does not catch sync errors or resource failures |
window.addEventListener('securitypolicyviolation', h) | CSP violations | The only way to detect CSP-blocked chunks |
window.addEventListener('vite:preloadError', h) | Vite's internal preload errors | Only dispatched if the main chunk already loaded and Vite runtime is running |
Both error and unhandledrejection are needed simultaneously — neither covers the other's cases.
Note on DevTools request blocking: when you block a request in DevTools, the error event does not fire. This is specific to DevTools — in real production (404, network failure) it fires normally. To test SPA Guard's behavior, use offline mode or actually remove a file from the server.
Why standard tools don't cover all cases
React Router ErrorBoundary
React Router v6+ attaches an errorElement to routes. If a lazy route's chunk fails to load, the ErrorBoundary catches it. But if the chunk is a utility module imported inside a component (not directly as a route), the error:
- Surfaces as
unhandledrejection - React Router ErrorBoundary does not see it
- A separate
window.addEventListener('unhandledrejection')is required
Sentry Loader Script
A small inline script in <head> that buffers error and unhandledrejection events until the Sentry SDK loads. It solves Case 3 for monitoring purposes.
Limitations: depends on Sentry's external CDN, provides monitoring only (no recovery/reload), no React-specific features, behavior cannot be customized.
vite:preloadError
Vite's built-in event for preload failures. It's dispatched inside Vite's JS runtime — meaning it only works after the main chunk has loaded and executed. If the main chunk failed to load, this event never fires.
Keeping old chunks on CDN
Never delete old chunk versions; keep them indefinitely.
Pros: fully eliminates Case 1. Cons: most CDNs and hosting platforms (Vercel, Cloudflare Pages) delete assets on deploy. Requires a custom deployment pipeline.
Coverage comparison
| Solution | Case 1 | Case 2 | Case 3 | Case 4 | Case 5 | Recovery |
|---|---|---|---|---|---|---|
| React Router ErrorBoundary | yes | yes | no | no | no | fallback UI |
| Sentry Loader Script | monitoring | monitoring | monitoring | monitoring | no | no reload |
vite:preloadError | yes | no | no | yes | no | manual reload |
| vite-plugin-pwa | yes | no | no | yes | no | from cache |
Polling version.json | proactive | no | no | no | no | proactive reload |
| Keep old chunks on CDN | yes | no | no | no | no | not needed |
| SPA Guard | yes | yes | yes | yes | yes | yes |
Why a simple location.reload() isn't enough
The naive approach is to catch chunk errors and reload:
window.addEventListener("unhandledrejection", (e) => {
if (e.reason?.message?.includes("Failed to fetch dynamically imported module")) {
location.reload();
}
});
This works in most situations. Here's why "most" isn't good enough:
The infrastructure context matters. Standard practice is to serve index.html with Cache-Control: no-cache and JS/CSS chunks with Cache-Control: max-age=31536000, immutable. With this setup, a simple reload fetches a fresh index.html with new chunk references. This works in 99% of cases.
The remaining 1% is the problem: if the network is fully unavailable, or the browser serves a cached no-cache response from disk cache (which some browsers do under certain conditions), the reload fetches the same stale HTML with the same broken chunk URLs — and you've created an infinite reload loop.
Beyond the cache problem:
- No loop protection: without tracking how many times a reload has happened, the error handler fires again after the reload, triggering another reload, indefinitely
- No user feedback: the page flashes and reloads with no explanation
- No visibility: if users are looping through reload cycles and giving up, there's no signal in your monitoring
- No fallback: after a reasonable number of retries, users should see a static message, not loop forever
What SPA Guard does
SPA Guard addresses all of this with a coordinated system:
Inline script before all chunks (injected by the Vite plugin): registers all four event listeners (error capture, unhandledrejection, securitypolicyviolation, vite:preloadError) before any application code runs. This is the only way to handle Case 3.
Two-level retry strategy:
lazyWithRetry(from@ovineko/spa-guard-react) retries the individualimport()call several times with short delays before escalating.- If module-level retry fails, the retry orchestrator schedules a full-page reload with cache-busting query parameters (
?spaGuardRetryAttempt=1&spaGuardRetryId=...&spaGuardCacheBust=...) — forcing the browser to bypass its HTML cache entirely.
Explicit retry state machine (idle → scheduled → fallback): prevents concurrent triggers, deduplicates reload scheduling, and transitions to a static fallback UI when retries are exhausted — so reload loops are impossible by design.
Loading UI during retry: while a reload is pending, a loading UI is injected into the DOM so users know something is happening.
Beacon error reporting: when SPA Guard handles an error, it sends a structured payload to your server via navigator.sendBeacon. This works even if the error occurred before Sentry initialized, and it works during page unload.
Next steps
- Overview — the full package family and architecture
- Integration Guide — add SPA Guard to your app in 15 minutes
- Why Server-Side HTML Cache? — the bootstrapping problem and how the node/fastify packages solve it