Which @angular/* package(s) are relevant/related to the feature request?
router, core
Description
Proposal: First-class version skew recovery for lazy-loaded chunks
Summary
When an app is deployed while a user has a tab open, subsequent lazy navigations can fail because the chunk the running client asks for no longer exists on the origin. Today this surfaces as an unhandled ChunkLoadError from a dynamic import(), and every application is left to rediscover the same recovery logic — usually a blanket location.reload() that discards unsaved work and, in a common CDN failure mode, loops forever.
This proposes a router feature, withVersionSkewRecovery(...), plus a small build-time manifest, that makes stale-chunk failures a declared, classifiable, handleable condition rather than a generic promise rejection.
It deliberately does not propose hot-swapping the new chunk into the running application. Rationale in Non-goals.
Motivation
The failure sequence is mundane and common:
- A user loads the app built from commit
A.
A is deployed over with commit B. Content-hashed chunk filenames change; the deployment pipeline removes the superseded files.
- The user — still running
A — navigates to a lazy route.
import('./chunk-A-hash.js') rejects with a 404.
The user is now stuck: the navigation fails, they remain on the previous route, and every subsequent lazy route in that tab will fail the same way.
Three properties make this worth solving in the framework rather than in each app:
It is not rendering-mode specific. A static CSR deployment on object storage hits this identically to an SSR deployment. The variable is how long a tab stays open, not how the first paint was produced.
Angular already owns the relevant artifacts. The CLI controls chunking, content hashing, and (for SSR) the server/client bundle pair. The information required to diagnose and recover exists at build time; it just isn't exposed at runtime.
The correct recovery is subtle. As shown below, a naive reload is wrong in at least four distinguishable situations. Each application rediscovering that is waste, and most stop at the naïve version.
Why existing pieces are not sufficient
withNavigationErrorHandler gives you the error, but no way to tell a purged chunk from an offline device from a route that was deleted in the new build. All three arrive as a failed dynamic import, and each wants a different response.
SwUpdate.versionUpdates (@angular/service-worker) reports that a new version exists. That is a useful signal, but it is availability detection, not failure recovery, and it requires the service worker to be enabled.
urlUpdateStrategy: 'deferred' (the default) means that after a failed navigation the address bar still shows the*previous route. A naive location.reload() therefore returns the user to where they started and silently discards the navigation they attempted. This is a subtle trap that most hand-rolled handlers fall into.
Proposed solution
Proposed API
Router feature
provideRouter(routes,
withVersionSkewRecovery({
onStaleChunk: 'reload-at-target', // default
retryAttempts: 1,
maxRecoveries: 1,
probeManifest: true,
respectDeactivateGuards: true,
})
);
Strategies
| Strategy |
Behaviour |
When appropriate |
'reload-at-target' |
Hard navigation to the intended URL (location.assign(targetUrl)), preserving the user's navigation intent. |
Default. |
'reload-in-place' |
location.reload(); abandons the attempted navigation. |
Target route is expendable; current view is expensive to rebuild. |
'redirect-to-fallback' |
Client-side redirect to a configured fallback route. |
The module no longer exists in the new build (route deleted). |
'notify' |
Take no automatic action; surface the condition and let the app decide. |
Unsaved work; or reloading is known-unsafe (see loop protection). |
'ignore' |
Let NavigationError propagate untouched. |
Apps with existing handling; tests. |
(ctx) => Action |
Application-supplied policy. |
Anything else. |
Context passed to a custom strategy
interface StaleChunkContext {
targetUrl: UrlTree;
currentUrl: UrlTree;
error: unknown;
attempt: number; // recoveries already spent this session
isOnline: boolean;
clientBuildId: string;
serverBuildId?: string; // present only if the manifest probe succeeded
moduleStillExists: boolean; // false => route removed in the new build
entryDocumentStale: boolean; // true => origin serving an old index.html
newAssetsReachable: boolean; // probed and warmed; a reload will succeed
hasBlockingGuard: boolean; // a CanDeactivate guard would block leaving
}
Which enables a policy that is actually correct in each case:
onStaleChunk: (ctx) =>
!ctx.moduleStillExists ? 'redirect-to-fallback' : // route deleted; reload would 404
ctx.entryDocumentStale ? 'notify' : // reloading would loop
ctx.hasBlockingGuard ? 'notify' : // unsaved work
ctx.newAssetsReachable ? 'reload-at-target' : // verified safe
'notify',
Completing a deferred recovery
'notify' must not be a dead end. Applications need a way to finish the recovery when the user is ready:
const skew = inject(VersionSkewService);
skew.pending(); // Signal<{ targetUrl: UrlTree; serverBuildId?: string } | null>
skew.recover(); // performs the deferred reload-at-target
skew.dismiss();
Behavior details
Retry is a precondition
Not every chunk failure is skew. A flaky network, a CDN edge miss, and a genuinely purged asset all surface as the same rejected import. Some deployment pipelines also delete-then-upload, producing a brief window where even unchanged filenames 404.
A bounded retry with short backoff resolves those without inflicting a reload on anyone, and should run prior to strategy dispatch:
import fails → retry(n) → still failing → classify → dispatch strategy
Classification: skew vs. transport
These require opposite responses; the raw error does not distinguish them:
navigator.onLine === false → not skew. Do not reload; a reload while offline replaces a working app with a browser error page.
- Manifest fetches and
serverBuildId !== clientBuildId → skew confirmed; reload is correct.
- Manifest unreachable → treat as transport; retry later or notify.
Loop protection
If the origin is serving a stale entry document — a CDN caching index.html, or a multi-region deploy with a lagging edge — then reloading re-fetches the same old bundle, which requests the same missing chunk, which reloads again. The tab is bricked.
A sessionStorage-backed attempt counter (maxRecoveries) is the blunt guard. The manifest probe enables the precise one: if the manifest is reachable but reports the same build ID the client is already running, the entry document is stale and reloading is known-futile. Degrade to 'notify' with a distinguishable error rather than looping.
Reuse CanDeactivate rather than inventing a "dirty" concept
A hard reload destroys in-memory state. The framework cannot know whether that matters — but the application has often already declared it via a CanDeactivate guard. When such a guard exists on the current route, a hard reload is presumptively wrong; respectDeactivateGuards: true degrades to 'notify'.
Surface is broader than the router
loadChildren, loadComponent, and @defer blocks all pull chunks, and incremental hydration expands the deferred surface further. The classification, probing, and loop-protection machinery should therefore live in a shared injectable service, with the router feature as one consumer rather than the owner.
Build-side requirement
The probe needs something to ask. This is a build output concern (and not just an SSR one).
Two pieces are needed:
1. A stable logical identity for each lazy entry point. At failure time the runtime holds chunk-ABC123.js, not ./features/admin/routes. The compiler already rewrites the dynamic import, so it can stamp a stable ID at the same point:
// authored
loadChildren: () => import('./admin/routes')
// compiled
loadChildren: ɵlazy('admin.routes#a1b2', () => import('./chunk-ABC123.js'))
2. A manifest mapping those IDs to current output files. The underlying bundler already produces this information (esbuild metafile; Vite manifest.json); it is not currently exposed as a stable, servable artifact.
Served with Cache-Control: no-store, this answers every question in StaleChunkContext. Where @angular/service-worker is enabled, ngsw.json already carries a timestamp and per-asset hashes and could serve as the source, avoiding a new artifact for those apps.
A companion APP_BUILD_ID injection token, populated at build time, gives the client its own identity for comparison. For SSR this additionally enables a build-ID handshake in the rendered document, which catches the other skew surface: a server on build B rendering markup that hydrates against a client bundle from build A.
Non-goals
Hot-swapping the new chunk into the running application
The appealing version of this feature is: resolve the failed module to its new URL, import that, continue the navigation, no reload, no lost state. The manifest above makes the resolution trivial. The loading is the problem.
A chunk from build B resolves its own imports against build B's shared chunks. Loading it into a page already running build A puts two copies of Angular core and the application's own shared services into one page. This is fatal rather than wasteful, because DI identity is object identity: build B's InjectionToken is a different object from build A's, so provider lookups miss, instanceof checks fail across the boundary, and signals created under one copy are not tracked by the other's reactive graph.
Rewriting the new chunk's imports to reuse build A's shared chunks does not rescue it either — the new code was compiled against build B's interfaces, so it would receive stale implementations and hope the shapes still align. This is the shared-singleton negotiation problem that consumes most of module federation's complexity budget, and it still leaks there.
There is also an irony that closes the door on the remaining cases: content hashing means an unchanged chunk keeps its filename across builds. The chunks that 404 are therefore precisely the ones whose content changed — which are precisely the ones whose shared graph is most likely to have moved. The safe-to-hot-load case is largely the case that either never 404s, or that a bounded retry already resolves.
Hot-swapping is legitimate where the loaded unit is isolated and carries its own runtime — an iframe-grade or Web-Fragment-style boundary with no shared injector. That is a different architecture, and out of scope here.
Other adjacent gaps
Server-driven cache invalidation are separate concerns and deliberately excluded, though they share the build-ID primitive proposed above.
Open questions
-
Default strategy. 'reload-at-target' is proposed as the default on the grounds that the navigation has already failed and the app is in a degraded state where subsequent lazy routes will likely fail too — leaving the user stranded with a toast is the worse outcome. Guarding it with respectDeactivateGuards places the safety valve where the app has declared that leaving is costly. Is that the right trade for a framework default, or should the default be non-destructive ('notify') with opt-in reloading?
-
Manifest as a new artifact vs. reusing ngsw.json. Reuse avoids a second artifact but couples the feature to the service worker package.
-
Should @defer block failures share this pipeline by default, or opt in separately? They have a different UX profile — a failed deferred block may be non-critical and better left as a placeholder than escalated to a page reload.
-
State preservation across recovery. An opt-in preserveState hook (serialise a snapshot before reload, restore after) would make reload-at-target non-destructive. Worth including in v1, or a follow-up?
-
Interaction with SwUpdate. When the service worker is present it may already know a new version exists. Should the feature consume that signal in preference to probing the manifest?
-
Naming. "Version skew" is Vercel's term and reasonably well understood, but withStaleChunkRecovery describes the observable failure more directly.
Prior art
- Vercel Skew Protection — solves this at the platform layer by routing requests to the deployment matching the client. Confirms the problem is real and material; the platform-level fix is unavailable to teams not on that platform (which is much of the Angular base!).
- Vite
manifest.json / esbuild metafile — the module-to-chunk mapping this proposal needs exists in the toolchain.
Alternatives considered
Alternatives considered
Leave it to applications via withNavigationErrorHandler. Possible today, and this is what apps do. But the classification step — skew vs. offline vs. deleted route vs. stale entry document — requires build metadata the framework has and applications do not. Without it, apps can only implement the naïve reload, including its loop.
Deployment hygiene alone. Retaining previous builds' hashed assets rather than purging them prevents most 404s outright, and is the cheapest available mitigation. It should be documented regardless. It does not cover CDN eviction, multi-region lag, or organizations without control of their deployment pipeline.
Service worker alone. @angular/service-worker serves cached assets after the origin has moved on and already exposes SwUpdate.versionUpdates. This converts much of the failure surface into a non-event. It does not fully close the gap: chunks marked lazy in ngsw.json that were never requested are not in the cache and can still 404, and not all applications can adopt a service worker.
Import maps for remapping. Would require the map to be declared before module loading and does not address the dual shared-graph problem.
Which @angular/* package(s) are relevant/related to the feature request?
router, core
Description
Proposal: First-class version skew recovery for lazy-loaded chunks
Summary
When an app is deployed while a user has a tab open, subsequent lazy navigations can fail because the chunk the running client asks for no longer exists on the origin. Today this surfaces as an unhandled
ChunkLoadErrorfrom a dynamicimport(), and every application is left to rediscover the same recovery logic — usually a blanketlocation.reload()that discards unsaved work and, in a common CDN failure mode, loops forever.This proposes a router feature,
withVersionSkewRecovery(...), plus a small build-time manifest, that makes stale-chunk failures a declared, classifiable, handleable condition rather than a generic promise rejection.It deliberately does not propose hot-swapping the new chunk into the running application. Rationale in Non-goals.
Motivation
The failure sequence is mundane and common:
A.Ais deployed over with commitB. Content-hashed chunk filenames change; the deployment pipeline removes the superseded files.A— navigates to a lazy route.import('./chunk-A-hash.js')rejects with a 404.The user is now stuck: the navigation fails, they remain on the previous route, and every subsequent lazy route in that tab will fail the same way.
Three properties make this worth solving in the framework rather than in each app:
It is not rendering-mode specific. A static CSR deployment on object storage hits this identically to an SSR deployment. The variable is how long a tab stays open, not how the first paint was produced.
Angular already owns the relevant artifacts. The CLI controls chunking, content hashing, and (for SSR) the server/client bundle pair. The information required to diagnose and recover exists at build time; it just isn't exposed at runtime.
The correct recovery is subtle. As shown below, a naive reload is wrong in at least four distinguishable situations. Each application rediscovering that is waste, and most stop at the naïve version.
Why existing pieces are not sufficient
withNavigationErrorHandlergives you the error, but no way to tell a purged chunk from an offline device from a route that was deleted in the new build. All three arrive as a failed dynamic import, and each wants a different response.SwUpdate.versionUpdates(@angular/service-worker) reports that a new version exists. That is a useful signal, but it is availability detection, not failure recovery, and it requires the service worker to be enabled.urlUpdateStrategy: 'deferred'(the default) means that after a failed navigation the address bar still shows the*previous route. A naivelocation.reload()therefore returns the user to where they started and silently discards the navigation they attempted. This is a subtle trap that most hand-rolled handlers fall into.Proposed solution
Proposed API
Router feature
Strategies
'reload-at-target'location.assign(targetUrl)), preserving the user's navigation intent.'reload-in-place'location.reload(); abandons the attempted navigation.'redirect-to-fallback''notify''ignore'NavigationErrorpropagate untouched.(ctx) => ActionContext passed to a custom strategy
Which enables a policy that is actually correct in each case:
Completing a deferred recovery
'notify'must not be a dead end. Applications need a way to finish the recovery when the user is ready:Behavior details
Retry is a precondition
Not every chunk failure is skew. A flaky network, a CDN edge miss, and a genuinely purged asset all surface as the same rejected import. Some deployment pipelines also delete-then-upload, producing a brief window where even unchanged filenames 404.
A bounded retry with short backoff resolves those without inflicting a reload on anyone, and should run prior to strategy dispatch:
Classification: skew vs. transport
These require opposite responses; the raw error does not distinguish them:
navigator.onLine === false→ not skew. Do not reload; a reload while offline replaces a working app with a browser error page.serverBuildId !== clientBuildId→ skew confirmed; reload is correct.Loop protection
If the origin is serving a stale entry document — a CDN caching
index.html, or a multi-region deploy with a lagging edge — then reloading re-fetches the same old bundle, which requests the same missing chunk, which reloads again. The tab is bricked.A
sessionStorage-backed attempt counter (maxRecoveries) is the blunt guard. The manifest probe enables the precise one: if the manifest is reachable but reports the same build ID the client is already running, the entry document is stale and reloading is known-futile. Degrade to'notify'with a distinguishable error rather than looping.Reuse
CanDeactivaterather than inventing a "dirty" conceptA hard reload destroys in-memory state. The framework cannot know whether that matters — but the application has often already declared it via a
CanDeactivateguard. When such a guard exists on the current route, a hard reload is presumptively wrong;respectDeactivateGuards: truedegrades to'notify'.Surface is broader than the router
loadChildren,loadComponent, and@deferblocks all pull chunks, and incremental hydration expands the deferred surface further. The classification, probing, and loop-protection machinery should therefore live in a shared injectable service, with the router feature as one consumer rather than the owner.Build-side requirement
The probe needs something to ask. This is a build output concern (and not just an SSR one).
Two pieces are needed:
1. A stable logical identity for each lazy entry point. At failure time the runtime holds
chunk-ABC123.js, not./features/admin/routes. The compiler already rewrites the dynamic import, so it can stamp a stable ID at the same point:2. A manifest mapping those IDs to current output files. The underlying bundler already produces this information (esbuild
metafile; Vitemanifest.json); it is not currently exposed as a stable, servable artifact.{ "buildId": "b57", "modules": { "admin.routes#a1b2": { "file": "chunk-XYZ789.js", "hash": "…" } } }Served with
Cache-Control: no-store, this answers every question inStaleChunkContext. Where@angular/service-workeris enabled,ngsw.jsonalready carries a timestamp and per-asset hashes and could serve as the source, avoiding a new artifact for those apps.A companion
APP_BUILD_IDinjection token, populated at build time, gives the client its own identity for comparison. For SSR this additionally enables a build-ID handshake in the rendered document, which catches the other skew surface: a server on buildBrendering markup that hydrates against a client bundle from buildA.Non-goals
Hot-swapping the new chunk into the running application
The appealing version of this feature is: resolve the failed module to its new URL, import that, continue the navigation, no reload, no lost state. The manifest above makes the resolution trivial. The loading is the problem.
A chunk from build
Bresolves its own imports against buildB's shared chunks. Loading it into a page already running buildAputs two copies of Angular core and the application's own shared services into one page. This is fatal rather than wasteful, because DI identity is object identity: buildB'sInjectionTokenis a different object from buildA's, so provider lookups miss,instanceofchecks fail across the boundary, and signals created under one copy are not tracked by the other's reactive graph.Rewriting the new chunk's imports to reuse build
A's shared chunks does not rescue it either — the new code was compiled against buildB's interfaces, so it would receive stale implementations and hope the shapes still align. This is the shared-singleton negotiation problem that consumes most of module federation's complexity budget, and it still leaks there.There is also an irony that closes the door on the remaining cases: content hashing means an unchanged chunk keeps its filename across builds. The chunks that 404 are therefore precisely the ones whose content changed — which are precisely the ones whose shared graph is most likely to have moved. The safe-to-hot-load case is largely the case that either never 404s, or that a bounded retry already resolves.
Hot-swapping is legitimate where the loaded unit is isolated and carries its own runtime — an iframe-grade or Web-Fragment-style boundary with no shared injector. That is a different architecture, and out of scope here.
Other adjacent gaps
Server-driven cache invalidation are separate concerns and deliberately excluded, though they share the build-ID primitive proposed above.
Open questions
Default strategy.
'reload-at-target'is proposed as the default on the grounds that the navigation has already failed and the app is in a degraded state where subsequent lazy routes will likely fail too — leaving the user stranded with a toast is the worse outcome. Guarding it withrespectDeactivateGuardsplaces the safety valve where the app has declared that leaving is costly. Is that the right trade for a framework default, or should the default be non-destructive ('notify') with opt-in reloading?Manifest as a new artifact vs. reusing
ngsw.json. Reuse avoids a second artifact but couples the feature to the service worker package.Should
@deferblock failures share this pipeline by default, or opt in separately? They have a different UX profile — a failed deferred block may be non-critical and better left as a placeholder than escalated to a page reload.State preservation across recovery. An opt-in
preserveStatehook (serialise a snapshot before reload, restore after) would makereload-at-targetnon-destructive. Worth including in v1, or a follow-up?Interaction with
SwUpdate. When the service worker is present it may already know a new version exists. Should the feature consume that signal in preference to probing the manifest?Naming. "Version skew" is Vercel's term and reasonably well understood, but
withStaleChunkRecoverydescribes the observable failure more directly.Prior art
manifest.json/ esbuildmetafile— the module-to-chunk mapping this proposal needs exists in the toolchain.Alternatives considered
Alternatives considered
Leave it to applications via
withNavigationErrorHandler. Possible today, and this is what apps do. But the classification step — skew vs. offline vs. deleted route vs. stale entry document — requires build metadata the framework has and applications do not. Without it, apps can only implement the naïve reload, including its loop.Deployment hygiene alone. Retaining previous builds' hashed assets rather than purging them prevents most 404s outright, and is the cheapest available mitigation. It should be documented regardless. It does not cover CDN eviction, multi-region lag, or organizations without control of their deployment pipeline.
Service worker alone.
@angular/service-workerserves cached assets after the origin has moved on and already exposesSwUpdate.versionUpdates. This converts much of the failure surface into a non-event. It does not fully close the gap: chunks markedlazyinngsw.jsonthat were never requested are not in the cache and can still 404, and not all applications can adopt a service worker.Import maps for remapping. Would require the map to be declared before module loading and does not address the dual shared-graph problem.