Ever since I started with Blazor, all my projects were server rendered apps, private tools or even fully local apps. So I never ran into the drawbacks of Blazor Server, and Blazor Hot Reload was always good enough that I never thought about it. Then I wanted to offer automatic render-mode switching via Blazor Hybrid, and for that I had to start working with Blazor WASM. That’s where I first hit the big Hot Reload pain point in the WASM world, the one people complain about all the time. So I wanted to use WASM but keep the .NET Hot Reload experience I knew from my server app.

In the end, I combine both in one codebase: a WASM entry point and the standard server entry point. The server entry point renders the server variant and also serves the WASM artifacts. My day-to-day work runs in server mode, and I check the WASM variant before shipping. The interesting part: a lot of what made this workaround necessary is no longer a problem. But to show how much has improved, I first need to explain how bad it used to be.


Where the bad reputation comes from

The bad reputation built up over years, and partly for good reason, especially on the WASM side. In .NET 6 and 7, Hot Reload via dotnet watch failed on WASM projects all the time. Up to .NET 7, dotnet watch logged the Hot Reload capabilities it had detected, the edits the runtime says it can apply. Since .NET 8 it no longer reports them for WASM. On WASM this list often came back empty on startup. With no capabilities Hot Reload has nothing to work with, so the whole thing was useless. A simple markup edit would then do nothing (No hot reload changes to apply).

The most-cited issue is literally called “Hot Reload nearly always fails, with Blazor WebAssembly and dotnet watch”. On top of that, many operations simply weren’t supported: in .NET 6 you couldn’t even patch in a new field or method. And in October 2021, Microsoft even wanted to remove Hot Reload from the cross-platform dotnet watch entirely and limit it to Visual Studio (dotnet/sdk#22247). That rightly caused a lot of protest, and it was reverted within a few days.

But all of that describes .NET 6 and 7. A lot has changed since then, and the last two releases did the most. Only a few narrow cases are still unsupported, and you can find the current capabilities in the MS Learn docs. .NET 8 unified the Hot Reload path for Server and WASM. .NET 10 then moved WASM fully onto the general engine (WasmEnableHotReload, on by default in Debug). So the bad reputation is about a version of Blazor that no longer exists in that form.


A quick explainer: how Blazor Hot Reload actually works

A brief look under the hood explains why the state survives edits and why server mode has the edge. When you save, dotnet watch and the Roslyn compiler compare old with new and compute a delta, a small bundle of metadata, IL, and debug changes. This delta is patched straight into the already-loaded assembly of the running process, and then Blazor re-renders. No restart, nothing written to disk, the process keeps running and only its code is swapped in place, which is exactly why the state survives.

Before .NET 8, Server and WASM had different patchers and capabilities. By now both support exactly the same changes, and the delay from change to patch to re-render is nearly the same. The only real difference is where the delta lands. That’s why my daily driver stays server (more on that below). In server mode it patches the .NET process on the server, and the result reaches the browser as a finished DOM diff over the SignalR circuit. In WASM mode the same delta is applied to the .NET runtime in the browser, which then re-renders locally.

Two special cases: with CSS the browser just swaps the file out, no patch and no reload, so the state stays. And if a change can’t be turned into a delta (a renamed method, a changed signature), it’s a Rude Edit. No patch helps, the app has to be rebuilt and restarted, and anything that lived only in memory is gone.


Lifecycle methods: what runs and what doesn’t

After a Hot Reload, only one thing matters at the component level: Blazor keeps the existing instance and just forces a re-render. That decides which methods run on a patch:

Lifecycle method On a Hot Reload patch
OnInitialized / OnInitializedAsync does not run
SetParametersAsync / OnParametersSet(Async) runs
ShouldRender does not run, the re-render is forced anyway
BuildRenderTree (the rendering itself) runs
OnAfterRender(Async) runs, with firstRender: false
Dispose / DisposeAsync does not run, the instance is not torn down

Rule of thumb: everything that runs also after the initial render (OnParametersSet, BuildRenderTree, OnAfterRender) picks up the change right away. Everything that runs once per instance does not. The key case is OnInitialized. Logic that lives only there (loading a model for the first time, an event subscribe) doesn’t take effect on the open page, so you still see the old state. Same for field initializers: a new or changed default value only takes effect once the component is built from scratch. That’s because the instance keeps its current fields. To test these edits, remount the component: refresh the page, navigate away and back, or change the @key value.


The setup: one Blazor Web App, deliberately controlled

Enough generalities, a look at my private setup: a single Blazor Web App on .NET 10, made of two thin projects:

  • Server (Microsoft.NET.Sdk.Web): the ASP.NET Core host. Renders server-side and serves the WASM artifacts.
  • Client (Microsoft.NET.Sdk.BlazorWebAssembly): just the entry point for the WASM runtime.

All the UI code, all components, and most services live in Razor Class Libraries that both projects reference. Only truly host-specific things (file system, certain browser APIs, internal vs. public URLs) are implemented per host behind a shared interface. That means WASM-specific in the client, server-specific in the server. The components never know where they run.

src/Frontend/
├── Apps/
│   ├── App.Server/   ← ASP.NET Core host, serves WASM
│   └── App.Client/   ← WASM entry point, never started on its own
├── Shared/           ← Razor Class Libraries (components, services)
└── Plugins/          ← feature plugins, also RCLs

The app decides which render mode applies at runtime, in one central place, switchable via an environment variable, with no separate build. For development, I force pure InteractiveServer (the loop feels best there), in production it stays InteractiveAuto (server first over the circuit, then handoff to WASM). The same server project is always launched, through two launch profiles:

[Server] Default      → pure InteractiveServer loop, my daily driver
[Interactive] Default → Interactive Auto, the real WASM path for checking

dotnet publish runs against the server project and pulls the client WASM in with it: one codebase, one deployable, two render modes.


What just works day to day now

80 to 90 % of my daily edits run cleanly through Hot Reload.
The examples all run in a nested component tree with state on several levels: open dialogs, selections, uploads in progress:

Text & markup. The everyday case: open the .razor, change it, save, done.

Blazor Hot Reload: live text change in the complex component tree, the editor page's state is preserved

 

New methods, fields & bindings. Exactly the edits that were still Rude Edits in .NET 6. A new method in the code-behind, connected to a button via OnClick, clicked, and the editor state stays fully intact.

New method AddBranchAsync() bound via OnClick, the click creates a branch without losing the editor state

 

Scoped CSS. Both global and scoped .razor.css reload instantly, and a new class takes effect without losing the current selection.

New rule in the scoped BranchCard.razor.css, selection and expanded cards stay intact

 

State preservation. More complex UI like popovers or dropdowns can be styled while open, without losing that open state. That’s what the GIF shows: I add a <p> to the open menu’s markup, it shows up live, and the menu stays open.

New paragraph element in the markup of the open menu, shows up live, the menu stays open

 

Generic components. A new entry in the list of a <BranchSelector TBranch="…"> shows up live in the dropdown, and the current selection stays. This holds even though the component is generic and uses its type argument deep in the tree.

New entry in the list of a generic BranchSelector component, the new branch shows up live in the Select Branch dropdown, the current selection stays


Don’t tilt, investigate

When Hot Reload doesn’t work, check your own setup first, because often the problem can be fixed right there. My case: the open menu from above, but with a Tailwind class that wasn’t in the bundle yet. My setup rebuilds the bundle when new classes appear, and suddenly the menu closed and the state was gone.

Before the fix: a new Tailwind class forces dotnet watch into a full reload, the circuit is rebuilt, the menu closes

The cause: dotnet watch swaps a single .css cleanly, no reload. But if several files change at once (a .css together with something that isn’t CSS), it does a full browser reload (dotnet/aspnetcore#54942). The Tailwind rebuild writes the source map .css.map next to tailwind.min.css at the same time, two files, one not CSS, so a full reload.

My fix is one line in the host .csproj that takes the maps out of the watch:

<ItemGroup>
  <!-- On a Tailwind rebuild only the .css then arrives: clean swap,
       no full reload, the state stays. -->
  <Content Update="wwwroot\css\*.map" Watch="false" />
</ItemGroup>

The same class takes effect live, the menu stays open.

Blazor Hot Reload after the fix: dotnet watch only sees the .css, swaps it, the class takes effect live, the menu stays open

 

Problems like these range from a one-line fix to an unsupported component nesting that pulls the whole render tree out of Hot Reload. A generic layout component of mine once caused that at the top level, after which nothing could be patched anymore. So it’s worth cutting complexity in your own setup before you blame .NET.


Disclaimer: what’s not smooth yet

To be clear, a few things still aren’t smooth:

  • Don’t debug during Hot Reload. With the dotnet watch CLI you have to re-attach to the assembly after every change. As soon as a patch is applied, the debugger loses its symbols. I keep the two separate: iterative changes via Hot Reload, heavy debugging in a dedicated session without it.
  • IDEs sometimes lag behind dotnet watch. Your IDE might not have the latest Hot Reload SDK changes yet. With Visual Studio you’re usually safe; with Rider I’ve seen changes arrive only later. For the full SDK scope the dotnet watch CLI is your best bet, but you give up the integrated IDE features. Tip: if the IDE acts up, run dotnet watch in the terminal, if it works there, it’s the IDE integration.
  • Some edits force a rebuild, like a brand-new scoped .razor.css file (workaround: create it up front, empty if you have to). You can automate the rebuild: I use dotnet watch with automatic restart on rude edits. Visual Studio caught up, in Visual Studio 2026 HotReloadAutoRestart rebuilds on its own, restarts, and re-attaches the debugger automatically (needs .NET 10):
<PropertyGroup>
  <HotReloadAutoRestart>true</HotReloadAutoRestart>
</PropertyGroup>

I’m not claiming .NET Hot Reload is perfect, or anywhere near a full JavaScript SPA framework. What’s still shaky is mostly the reliability: a lot of the old WASM failures were closed without the root cause ever being confirmed as fixed (they basically rewrote the WASM Hot Reload path). So it’s come a long way, with room to improve. Still, I want to give it credit: the .NET Hot Reload world has improved enormously over the last releases. If you’re still on older .NET versions, consider an upgrade.


Why my daily driver stays server

If the mechanism is the same on both hosts, why don’t I just develop in WASM mode, the one that ships? Because the place where the patch lands makes the difference day to day. My daily driver stays server, for two reasons:

  1. Debugging. In server mode the debugger sits inside the running .NET process, in the familiar CoreCLR environment, with no Mono runtime in between: set a breakpoint, hit it, step through. In WASM mode it attaches to the browser, with its own symbol paths, which works (most of the time) but is clearly less fun.
  2. Rude Edits are almost invisible. The build takes the same time in both modes, the difference is in how fast it comes back up. In server mode I don’t re-download the WASM runtime. Thanks to the much-improved reconnect logic (.NET 9/10) the browser reconnects to the server process almost instantly. So without doing anything I’m back in the same state. A WASM app has to reload and start up the whole runtime with all assemblies, the infamous three to four seconds that break your flow. Often I don’t even notice a Rude Edit was needed. That was one of my biggest pain points back when I worked the first time with WASM.

Learnings from Hot Reload failures

Working with Hot Reload had a nice side effect that helps both development and the user: today I deliberately persist more state. If you don’t just keep that state in memory, but actually save it on the user’s side, then after a rebuild you’re back exactly where you were in a couple of clicks. That works in the URL (deep-linkable), in local/session storage, in cookies, or server-side. If you keep most of your state only in memory, you’ll quickly hit its limits during Hot Reloading. And so will the user, who otherwise loses everything on a reload. A Rude Edit, by the way, is a good test for where a step is still missing, and you once again get frustrated over a reload.


TL;DR

  • Reputation outdated: since .NET 8 the same Hot Reload path for Server and WASM, with .NET 10 WASM is solid too. 80 to 90 % of daily edits run live, without losing state.
  • How it works: on every save dotnet watch and Roslyn compute a delta that patches the running process. No restart, the state stays. Server patches in-process, WASM in the browser, only a Rude Edit forces a real rebuild.
  • Lifecycle stays put: the patch re-renders the existing component (BuildRenderTree, OnParametersSet, OnAfterRender) and keeps its instance. OnInitialized doesn’t run on a patch, edits to init logic need a remount.
  • Setup: one Blazor Web App, UI in Razor Class Libraries, only host-specific things behind shared interfaces. Pure server mode in daily work, production from the same artifact in auto mode.
  • Server as daily driver: nicer debugging, and after a Rude Edit barely noticeable thanks to auto-reconnect.
  • Check your own setup first: not every rebuild is Blazor’s fault.
  • Persist state: it survives rebuilds and directly helps your users.

Coming up: Tailwind CSS Hot Reload

The Tailwind class from the fix above had a reason. In my project Tailwind runs with real Hot Reload: a full native recompile of the bundle including all plugins. It picks up every class, even the ones that only show up at runtime. And I don’t run any separate CLI for it, it’s all embedded in the dotnet run or dotnet watch command. How to hook this watcher into the workflow is coming in the next post.


Note: all code examples come from private projects, chosen to show more complexity than the standard counter-example app. None of it shows any client project code.

.NET Developer with more than 8 years of industry experience. I have worked on desktop applications ranging from WinForms to WPF, developed API services for B2B customer workflows, and built product prototypes using the first alpha release of the Blazor framework. Since then, I have invested thousands of hours in web development with Blazor, Angular, and other SPA frameworks. My greatest achievement to date has been leading the migration of a complex Product Information Management (PIM) system for a major omnichannel retailer from a custom-built solution to an industry-standard platform. The migration was completed without degrading or losing any existing workflows, while keeping all dependent systems synchronized throughout the process - with zero downtime.