Enhanced Navigation: Why Your JavaScript Suddenly Stops Working in Blazor
You add some JavaScript to a Blazor page, test it locally, and everything runs as expected. Then you navigate to another route, click back, and nothing happens. Press F5 to refresh, and it works again.
When this happens, it’s easy to blame timing issues, Blazor lifecycle quirks, bad CSS selectors, or browser caching. But more often than not, your JavaScript isn't broken—your assumptions about the browser's document lifecycle are.
The real problem? The browser never actually loaded a new page.
Modern Blazor Web Apps use enhanced navigation, which fetches new HTML in the background and patches the DOM without executing a full document reload. It makes navigation feel instantaneous, but it completely upends a mental model developers have relied on for decades: navigating to a page doesn't mean the browser re-executes that page's scripts.
Let's break down why this breaks traditional JavaScript, how to reproduce it, and how to fix it cleanly using native Blazor capabilities.
The Traditional Browser Model
For most of the history of web development, client-side navigation was easy to reason about. When a user moved from /home to /products, the browser executed a full document swap:
/home
│
▼
HTTP Request
│
▼
Server returns /products HTML
│
▼
Browser destroys old document
│
▼
Browser creates new document
│
▼
Scripts execute ──► DOMContentLoaded ──► Page Ready
Because every navigation created a fresh document, setup scripts attached to the document load worked automatically:
document.addEventListener("DOMContentLoaded", () => {
console.log("The page loaded.");
initializeWidgets();
});
When the user navigated away and returned, a new document was created, DOMContentLoaded fired again, and initializeWidgets() executed predictably.
How Enhanced Navigation Breaks That Model
Blazor Web Apps bypass the traditional browser lifecycle for same-origin links. Instead of letting the browser tear down the document, Blazor fetches the destination HTML behind the scenes and updates the DOM in place:
Traditional Navigation:
Page A ──► Destroy Document ──► Load Page B ──► Execute Scripts
Enhanced Navigation:
Page A ──► Fetch Page B HTML ──► Patch Existing DOM ──► Page B Rendered
Because the original document never dies, DOMContentLoaded fires exactly once—when the user first visits the app.
Microsoft's Blazor documentation explicitly warns that page-specific JavaScript initialization may not execute as expected when static server-rendered (SSR) pages use enhanced navigation.
Reproducing the Issue
To see this in action, create a new Blazor Web App with two simple pages: / and /weather.
Components/Pages/Home.razor
@page "/"
<PageTitle>Home</PageTitle>
<h1>Home</h1>
<p>This page contains JavaScript-enhanced content.</p>
<div id="welcome-message" class="alert alert-secondary">
Waiting for JavaScript...
</div>
<p><a href="/weather">Go to Weather</a></p>
wwwroot/js/home.js
function initializeHomePage() {
const message = document.getElementById("welcome-message");
if (!message) return;
message.textContent = "JavaScript initialized this page.";
message.classList.remove("alert-secondary");
message.classList.add("alert-success");
console.log("Home page initialized.");
}
// Traditional hookup
document.addEventListener("DOMContentLoaded", initializeHomePage);
When you first open /, the script finds the element and updates the UI. But if you click Go to Weather and then hit the browser back button:
Blazor patches the DOM to restore the Home page markup.
The
#welcome-messagediv resets to"Waiting for JavaScript...".DOMContentLoadeddoes not fire because the document was never destroyed.Your initialization code never runs.
Pressing F5 forces a full document reload, firing DOMContentLoaded and masking the underlying issue during testing.
The Broader Impact on Third-Party Libraries
This doesn't just affect custom scripts. Any library that relies on initial page scans will break under enhanced navigation:
UI Tooltips & Popovers (Bootstrap, Tippy.js)
Syntax Highlighters (Prism.js, Highlight.js)
Charts & Datagrid Controls (Chart.js, DataTables)
WYSIWYG Editors (TinyMCE, Quill)
When Blazor replaces DOM nodes during navigation, any event listeners or wrappers attached to the old nodes are discarded, leaving the newly inserted HTML completely uninitialized.
Don't Put <script> Tags Inside Components
A common workaround is placing inline scripts directly inside Razor components:
@page "/customers"
<h1>Customers</h1>
<script>
initializeCustomers();
</script>
Relying on page-specific <script> elements inside components using enhanced navigation is fragile. Browsers handle dynamically inserted <script> tags inconsistently during DOM patching, and Microsoft explicitly advises against this pattern in production apps.
Instead, you need a mechanism to let Blazor tell your JavaScript when an enhanced navigation cycle completes.
Hooking Into Blazor's Navigation Events
Blazor exposes three global JavaScript events to handle the enhanced navigation lifecycle:
enhancednavigationstart: Fires before navigation begins.enhancednavigationend: Fires after the navigation finishes.enhancedload: Fires whenever Blazor finishes updating page content (including streaming SSR updates).
For running script initializations, enhancedload is the primary event to target.
The App-Level Fix
Create a JavaScript initializer file named wwwroot/app.lib.module.js (matching your assembly/app name so Blazor auto-discovers it):
function initializePage() {
const welcomeMessage = document.getElementById("welcome-message");
if (welcomeMessage) {
welcomeMessage.textContent = "JavaScript initialized this page.";
welcomeMessage.classList.remove("alert-secondary");
welcomeMessage.classList.add("alert-success");
}
}
export function afterWebStarted(blazor) {
// Handle initial application startup
initializePage();
// Handle subsequent enhanced navigation updates
blazor.addEventListener("enhancedload", () => {
initializePage();
});
}
The afterWebStarted hook runs automatically after the Blazor framework boots. Registering your setup function under enhancedload ensures your scripts re-run every time the DOM is updated.
Requirement: Initialization Code Must Be Idempotent
Because enhancedload fires on every DOM patch, your scripts will run multiple times during a single session. Initialization logic must be idempotent—meaning executing it multiple times produces the exact same outcome without side effects.
Dangerous (Non-Idempotent)
function initializeButtons() {
document.querySelectorAll(".special-button").forEach(button => {
// Attaches a new listener every time navigation occurs!
button.addEventListener("click", handleClick);
});
}
Safe (Idempotent)
function initializeButtons() {
document.querySelectorAll(".special-button").forEach(button => {
if (button.dataset.initialized) return;
button.dataset.initialized = "true";
button.addEventListener("click", handleClick);
});
}
Alternative: Event Delegation
Even better than tracking flags on individual elements is attaching a single, persistent event listener to the document root. Because the document node survives enhanced navigations, delegated listeners keep working seamlessly as child elements are swapped in and out:
export function afterWebStarted(blazor) {
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-action]");
if (!button) return;
if (button.dataset.action === "copy-value") {
copyValue(button);
}
});
}
Handling Resource Cleanup
Initialization is only half the battle. If a page starts background processes or registers window-level listeners, those resources persist after the user navigates away, causing memory leaks and unexpected behavior.
Common sources of leaks under enhanced navigation include:
setIntervalandsetTimeoutloopsResizeObserverandMutationObserverinstancesWindow or document-level event listeners (
window.addEventListener('resize', ...))Chart or WebGL instances bound to removed canvas elements
Page-specific JavaScript requires a structured lifecycle covering three distinct phases: Load, Update, and Dispose.
Building a Reusable Page-Script Architecture
To manage page-level JS cleanly in static SSR applications, you can combine a collocated JS module, a lightweight custom HTML element, and a Blazor initializer.
1. The Collocated Script (ProjectDashboard.razor.js)
let timerId = null;
let seconds = 0;
export function onLoad() {
seconds = 0;
startTimer();
}
export function onUpdate() {
updateDisplay();
}
export function onDispose() {
stopTimer();
}
function startTimer() {
stopTimer();
timerId = window.setInterval(() => {
seconds++;
updateDisplay();
}, 1000);
}
function stopTimer() {
if (timerId !== null) {
window.clearInterval(timerId);
timerId = null;
}
}
function updateDisplay() {
const element = document.getElementById("page-active-seconds");
if (element) {
element.textContent = seconds.toString();
}
}
2. The Blazor Wrapper Component (PageScript.razor)
<page-script src="@Src"></page-script>
@code {
[Parameter, EditorRequired]
public string Src { get; set; } = default!;
}
3. The Lifecycle Manager Initializer (wwwroot/MyApp.lib.module.js)
const pageScripts = new Map();
export function afterWebStarted(blazor) {
registerPageScriptElement();
blazor.addEventListener("enhancedload", handleEnhancedLoad);
}
function registerPageScriptElement() {
if (customElements.get("page-script")) return;
customElements.define("page-script", class extends HTMLElement {
static observedAttributes = ["src"];
attributeChangedCallback(name, oldValue, newValue) {
if (name === "src") {
unregisterScript(oldValue);
registerScript(newValue);
}
}
disconnectedCallback() {
unregisterScript(this.getAttribute("src"));
}
});
}
function registerScript(src) {
if (!src) return;
let scriptInfo = pageScripts.get(src);
if (scriptInfo) {
scriptInfo.referenceCount++;
return;
}
scriptInfo = { referenceCount: 1, module: null };
pageScripts.set(src, scriptInfo);
loadModule(src, scriptInfo);
}
function unregisterScript(src) {
if (!src) return;
const scriptInfo = pageScripts.get(src);
if (scriptInfo) {
scriptInfo.referenceCount--;
}
}
async function loadModule(src, scriptInfo) {
let moduleUrl = src;
if (moduleUrl.startsWith("./")) {
moduleUrl = new URL(moduleUrl.substring(2), document.baseURI).toString();
}
const module = await import(moduleUrl);
if (scriptInfo.referenceCount <= 0) return;
scriptInfo.module = module;
module.onLoad?.();
module.onUpdate?.();
}
function handleEnhancedLoad() {
// 1. Clean up modules no longer present in the DOM
for (const [src, scriptInfo] of pageScripts) {
if (scriptInfo.referenceCount <= 0) {
scriptInfo.module?.onDispose?.();
pageScripts.delete(src);
}
}
// 2. Notify surviving modules of the DOM update
for (const scriptInfo of pageScripts.values()) {
scriptInfo.module?.onUpdate?.();
}
}
4. Using It in a Component
@page "/project-dashboard"
<PageScript Src="./Components/Pages/ProjectDashboard.razor.js" />
<h1>Project Dashboard</h1>
<div class="card">
<div class="card-body">
Page active for: <strong id="page-active-seconds">0</strong> seconds
</div>
</div>
With this pattern, your component cleanly declares its JavaScript dependency, while the custom element and Blazor events handle importing, updating, and disposing of the module automatically during navigation.
Static SSR vs. Interactive Blazor
It's important to differentiate static SSR enhanced navigation from interactive rendering modes (InteractiveServer, InteractiveWebAssembly, InteractiveAuto).
| Context | Recommended JS Pattern | Primary Hook |
| Interactive Components | IJSRuntime + Collocated JS Modules | OnAfterRenderAsync / IAsyncDisposable |
| Static SSR + Enhanced Navigation | Initializer Modules + Custom Lifecycles | afterWebStarted / enhancedload |
If a component is interactive, OnAfterRenderAsync(firstRender) and C# interop handle initialization reliably. The enhancedload lifecycle specifically targets static SSR pages where components do not retain active client-side C# instances between navigations.
Testing for Enhanced Navigation Regression
Because manual testing often involves direct page refreshes, enhanced navigation bugs easily escape into production. Automating navigation sequences in Playwright or Selenium is the most effective way to catch them early.
Here is an example E2E test verifying script execution across page transitions:
[Test]
public async Task Tooltip_Should_Persist_Across_Enhanced_Navigations()
{
await Page.GotoAsync("https://localhost:5001/");
// 1. Verify tooltip works on initial load
var refreshBtn = Page.GetByRole(AriaRole.Button, new() { Name = "Refresh" });
await refreshBtn.HoverAsync();
await Expect(Page.Locator(".app-tooltip")).ToBeVisibleAsync();
// 2. Navigate away and return via client-side links
await Page.GetByRole(AriaRole.Link, new() { Name = "Weather" }).ClickAsync();
await Page.GetByRole(AriaRole.Link, new() { Name = "Home" }).ClickAsync();
// 3. Verify tooltip initialization executed again
refreshBtn = Page.GetByRole(AriaRole.Button, new() { Name = "Refresh" });
await refreshBtn.HoverAsync();
await Expect(Page.Locator(".app-tooltip")).ToBeVisibleAsync();
}
A Quick Checklist for Blazor JS Integration
Before shipping JavaScript in a Blazor Web App, run through these quick checks:
Does this script rely on
DOMContentLoadedorwindow.onload? Convert it to register with Blazor'senhancedloadevent.Is initialization idempotent? Ensure re-running setup functions won't duplicate event listeners or UI elements.
Does the script scope its DOM queries? Target container IDs rather than broad document selectors (e.g.,
container.querySelectorAll(...)).Is cleanup handled on navigation away? Remove global listeners, clear timers, and call
.destroy()on third-party widgets inside a disposal step.Can native HTML do the job instead? Re-evaluate whether HTML elements like
<dialog>or thepopoverattribute can replace JavaScript completely.

Comments
Post a Comment