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:

Plaintext

/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:

JavaScript

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:

Plaintext

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

HTML

@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

JavaScript

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:

  1. Blazor patches the DOM to restore the Home page markup.

  2. The #welcome-message div resets to "Waiting for JavaScript...".

  3. DOMContentLoaded does not fire because the document was never destroyed.

  4. 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:

HTML

@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):

JavaScript

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)

JavaScript

function initializeButtons() {
    document.querySelectorAll(".special-button").forEach(button => {
        // Attaches a new listener every time navigation occurs!
        button.addEventListener("click", handleClick);
    });
}

Safe (Idempotent)

JavaScript

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:

JavaScript

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:

  • setInterval and setTimeout loops

  • ResizeObserver and MutationObserver instances

  • Window 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)

JavaScript

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)

HTML

<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)

JavaScript

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

HTML

@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).

ContextRecommended JS PatternPrimary Hook
Interactive ComponentsIJSRuntime + Collocated JS ModulesOnAfterRenderAsync / IAsyncDisposable
Static SSR + Enhanced NavigationInitializer Modules + Custom LifecyclesafterWebStarted / 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:

C#

[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:

  1. Does this script rely on DOMContentLoaded or window.onload? Convert it to register with Blazor's enhancedload event.

  2. Is initialization idempotent? Ensure re-running setup functions won't duplicate event listeners or UI elements.

  3. Does the script scope its DOM queries? Target container IDs rather than broad document selectors (e.g., container.querySelectorAll(...)).

  4. Is cleanup handled on navigation away? Remove global listeners, clear timers, and call .destroy() on third-party widgets inside a disposal step.

  5. Can native HTML do the job instead? Re-evaluate whether HTML elements like <dialog> or the popover attribute can replace JavaScript completely.

 

Comments

Popular posts from this blog

Customizing PWA Manifest and Icons for a Polished User Experience 🚀

Offline-First Strategy with Blazor PWAs: A Complete Guide 🚀

Yes, Blazor Server can scale!