Skip to main content

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:

EventWhat it catchesNotes
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 violationsThe only way to detect CSP-blocked chunks
window.addEventListener('vite:preloadError', h)Vite's internal preload errorsOnly 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:

  1. Surfaces as unhandledrejection
  2. React Router ErrorBoundary does not see it
  3. 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

SolutionCase 1Case 2Case 3Case 4Case 5Recovery
React Router ErrorBoundaryyesyesnononofallback UI
Sentry Loader Scriptmonitoringmonitoringmonitoringmonitoringnono reload
vite:preloadErroryesnonoyesnomanual reload
vite-plugin-pwayesnonoyesnofrom cache
Polling version.jsonproactivenonononoproactive reload
Keep old chunks on CDNyesnononononot needed
SPA Guardyesyesyesyesyesyes

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:

  1. lazyWithRetry (from @ovineko/spa-guard-react) retries the individual import() call several times with short delays before escalating.
  2. 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