BlazorWebView vs HybridWebView in .NET MAUI: Which Hybrid Approach Is Right for You?

A practical guide comparing BlazorWebView and HybridWebView in .NET MAUI. Covers architecture patterns, code sharing with Razor Class Libraries, .NET 10 enhancements, performance optimization, and a decision framework to help you choose the right hybrid approach.

Building Hybrid Apps with .NET MAUI: BlazorWebView vs HybridWebView — A Complete Architecture Guide

If you've been building mobile apps for any length of time, you've probably watched the hybrid landscape evolve at a dizzying pace. We went from Apache Cordova wrapping entire web apps in a thin native shell to today's sophisticated architectures where native platform capabilities and web-rendered UI actually work together seamlessly. It's been quite the journey.

With .NET MAUI, Microsoft has pushed things even further by offering not one but two distinct WebView-based approaches: BlazorWebView and HybridWebView. And honestly, the differences between them matter more than you might think.

BlazorWebView brings the full power of Blazor's component model into a native MAUI shell, letting C# developers build rich UI without touching JavaScript. HybridWebView takes the opposite approach — it opens the door to hosting any web technology (React, Vue, Angular, vanilla HTML/JS/CSS, you name it) inside a native MAUI application with deep interop capabilities.

With .NET 10 bringing significant enhancements to both approaches, understanding when and how to use each one has never been more critical. This guide walks through a comprehensive architectural comparison with production-ready code examples, performance strategies, and a practical decision framework to help you pick the right path for your next project.

Understanding the Hybrid App Landscape in .NET MAUI

In the .NET MAUI context, "hybrid" refers to a specific architectural pattern: a native application shell that hosts web-rendered UI within an embedded WebView control. This is fundamentally different from purely native approaches (where every UI element is a platform control) and purely web-based ones (where the entire app runs in a browser).

The hybrid model offers a compelling middle ground. Your app gets installed as a native app, has access to native APIs through .NET MAUI's platform abstraction layer, and appears in the device's app store. But significant portions of the UI are rendered using web technologies — HTML, CSS, and either C# (via Blazor) or JavaScript.

Why Hybrid Matters for Modern Teams

The decision to go hybrid is rarely purely technical. It's driven by organizational realities:

  • Existing web investments — Teams with large Blazor or JavaScript codebases want to reuse that code on mobile without a complete rewrite. And who can blame them?
  • Skill availability — Web developers significantly outnumber native mobile developers. Hybrid approaches lower the barrier to mobile development considerably.
  • Code sharing requirements — Organizations maintaining both web and mobile applications need to share UI components across targets to reduce duplication.
  • Rapid iteration — Web-rendered UI can often be updated more quickly than fully native interfaces, especially when leveraging hot reload.
  • Progressive complexity — Teams can start with web-rendered UI and progressively replace components with native controls where performance demands it.

.NET MAUI uniquely positions itself by supporting both the Blazor ecosystem and the broader JavaScript ecosystem within the same hybrid architecture. That means you're not locked into a single web technology stack when choosing .NET MAUI as your native host — which is a pretty big deal.

BlazorWebView Deep Dive

BlazorWebView is a .NET MAUI control that hosts a Blazor application inside a native WebView. Unlike Blazor Server (which requires a persistent SignalR connection) or Blazor WebAssembly (which runs in a browser sandbox), Blazor Hybrid runs .NET code directly on the device via the .NET runtime. The Blazor framework renders its component tree to HTML, which is then displayed in the native platform's WebView control — WebView2 on Windows, WKWebView on iOS/macOS, and WebView on Android.

How BlazorWebView Works Under the Hood

The architecture follows a clean separation of concerns. The .NET runtime executes Blazor component logic natively on the device. When a component renders, Blazor produces a render tree diff, serializes it, and sends it to the WebView via an internal interop channel. The WebView applies these diffs to its DOM. User interactions (clicks, input changes, form submissions) get captured and routed back to the .NET runtime, where the Blazor component handles them and potentially triggers a new render cycle.

Here's the part I find really elegant: there's no web server involved. The HTML, CSS, and JavaScript assets Blazor needs are served through a custom app:// scheme handler that reads files directly from the application bundle. Your app works fully offline and avoids the latency penalties of network requests.

Setting Up BlazorWebView in a .NET MAUI App

Let's walk through a complete setup. First, the MAUI project needs the Blazor Hybrid dependencies and configuration:

// MauiProgram.cs
using Microsoft.Extensions.Logging;

namespace MobileTechLead.HybridDemo;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // Register Blazor WebView
        builder.Services.AddMauiBlazorWebView();

#if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
        builder.Logging.AddDebug();
#endif

        // Register application services
        builder.Services.AddSingleton<IDeviceLocationService, MauiDeviceLocationService>();
        builder.Services.AddSingleton<ISecureStorageService, MauiSecureStorageService>();
        builder.Services.AddHttpClient("ApiClient", client =>
        {
            client.BaseAddress = new Uri("https://api.example.com/");
        });

        return builder.Build();
    }
}

Next, the main page hosts the BlazorWebView control in XAML:

<!-- MainPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:MobileTechLead.HybridDemo"
             x:Class="MobileTechLead.HybridDemo.MainPage">

    <BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
        <BlazorWebView.RootComponents>
            <RootComponent Selector="#app" ComponentType="{x:Type local:Routes}" />
        </BlazorWebView.RootComponents>
    </BlazorWebView>

</ContentPage>

The wwwroot/index.html file serves as the HTML host:

<!-- wwwroot/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>MobileTechLead Hybrid Demo</title>
    <link rel="stylesheet" href="css/app.css" />
    <link rel="stylesheet" href="MobileTechLead.HybridDemo.styles.css" />
</head>
<body>
    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">Dismiss</a>
    </div>

    <script src="_framework/blazor.webview.js"></script>
</body>
</html>

And here's a practical Razor component that shows off native platform integration — this is where things get interesting:

@* Components/Pages/DeviceInfo.razor *@
@page "/device-info"
@using Microsoft.Maui.Devices
@inject IDeviceLocationService LocationService

<h3>Device Information</h3>

<div class="card">
    <div class="card-body">
        <p><strong>Platform:</strong> @DeviceInfo.Current.Platform</p>
        <p><strong>Device Type:</strong> @DeviceInfo.Current.DeviceType</p>
        <p><strong>OS Version:</strong> @DeviceInfo.Current.VersionString</p>
        <p><strong>Manufacturer:</strong> @DeviceInfo.Current.Manufacturer</p>
    </div>
</div>

@if (currentLocation is not null)
{
    <div class="card mt-3">
        <div class="card-body">
            <h4>Current Location</h4>
            <p>Latitude: @currentLocation.Latitude</p>
            <p>Longitude: @currentLocation.Longitude</p>
        </div>
    </div>
}

<button class="btn btn-primary mt-3" @onclick="GetLocationAsync" disabled="@isLoading">
    @if (isLoading)
    {
        <span>Locating...</span>
    }
    else
    {
        <span>Get Current Location</span>
    }
</button>

@code {
    private LocationResult? currentLocation;
    private bool isLoading;

    private async Task GetLocationAsync()
    {
        isLoading = true;
        try
        {
            currentLocation = await LocationService.GetCurrentLocationAsync();
        }
        catch (PermissionException)
        {
            await App.Current!.Windows[0].Page!
                .DisplayAlert("Permission Denied",
                    "Location permission is required.", "OK");
        }
        finally
        {
            isLoading = false;
        }
    }
}

HybridWebView Deep Dive

While BlazorWebView is built specifically for hosting Blazor applications, HybridWebView takes a much more agnostic approach. It lets you host any web content — a React SPA, a Vue.js dashboard, a vanilla HTML/CSS/JS app, even a pre-existing web application — inside a .NET MAUI native shell.

The key differentiator from simply using a standard WebView control? HybridWebView provides a bidirectional communication bridge between JavaScript in the WebView and C# in the .NET runtime. You get structured, type-safe communication between the two worlds, which makes a huge difference in practice.

How HybridWebView Differs from BlazorWebView

The fundamental difference is where the UI logic executes. With BlazorWebView, your UI logic is C# code running in the .NET runtime — the WebView is purely a rendering surface. With HybridWebView, your UI logic is JavaScript code running in the WebView, and the .NET runtime provides native capabilities through interop.

This distinction has profound implications for architecture, debugging, performance, and team skill requirements. It's not just a minor implementation detail.

Complete HybridWebView Setup with JavaScript Interop

Let's build a complete example showing bidirectional communication between a JavaScript frontend and C# backend:

<!-- MainPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MobileTechLead.HybridDemo.MainPage">

    <HybridWebView x:Name="hybridWebView"
                    DefaultFile="index.html"
                    HybridRoot="Resources/Raw/hybrid_app"
                    RawMessageReceived="OnRawMessageReceived" />

</ContentPage>

The code-behind handles JavaScript interop and exposes native functionality:

// MainPage.xaml.cs
using System.Text.Json;

namespace MobileTechLead.HybridDemo;

public partial class MainPage : ContentPage
{
    public MainPage()
    {
        InitializeComponent();

        // Register C# methods that JavaScript can invoke
        hybridWebView.SetInvokeJavaScriptTarget(this);
    }

    // This method can be called from JavaScript
    [JSInvokable]
    public async Task<DeviceInfoResult> GetDeviceInfo()
    {
        var location = await Geolocation.Default.GetLastKnownLocationAsync();

        return new DeviceInfoResult
        {
            Platform = DeviceInfo.Current.Platform.ToString(),
            OsVersion = DeviceInfo.Current.VersionString,
            DeviceModel = DeviceInfo.Current.Model,
            Manufacturer = DeviceInfo.Current.Manufacturer,
            BatteryLevel = Battery.Default.ChargeLevel,
            Latitude = location?.Latitude,
            Longitude = location?.Longitude
        };
    }

    [JSInvokable]
    public async Task<string> ReadFromSecureStorage(string key)
    {
        return await SecureStorage.Default.GetAsync(key) ?? string.Empty;
    }

    [JSInvokable]
    public async Task WriteToSecureStorage(string key, string value)
    {
        await SecureStorage.Default.SetAsync(key, value);
    }

    // Handle raw messages from JavaScript
    private async void OnRawMessageReceived(object? sender,
        HybridWebViewRawMessageReceivedEventArgs e)
    {
        var message = JsonSerializer.Deserialize<JsMessage>(e.Message!);

        if (message?.Type == "navigate")
        {
            await Shell.Current.GoToAsync(message.Payload);
        }
        else if (message?.Type == "share")
        {
            await Share.Default.RequestAsync(new ShareTextRequest
            {
                Text = message.Payload,
                Title = "Share from Hybrid App"
            });
        }
    }

    // Invoke JavaScript from C# when needed
    private async Task NotifyJavaScript(string eventName, object data)
    {
        var json = JsonSerializer.Serialize(data);
        await hybridWebView.InvokeJavaScriptAsync(
            "handleNativeEvent",
            HybridWebViewProxyContext.Default.String,
            [eventName, json],
            [HybridWebViewProxyContext.Default.String,
             HybridWebViewProxyContext.Default.String]);
    }
}

public record DeviceInfoResult
{
    public string Platform { get; init; } = "";
    public string OsVersion { get; init; } = "";
    public string DeviceModel { get; init; } = "";
    public string Manufacturer { get; init; } = "";
    public double BatteryLevel { get; init; }
    public double? Latitude { get; init; }
    public double? Longitude { get; init; }
}

public record JsMessage
{
    public string Type { get; init; } = "";
    public string Payload { get; init; } = "";
}

On the JavaScript side, communicating with C# through the interop bridge looks like this:

// Resources/Raw/hybrid_app/scripts/app.js

// Initialize the hybrid bridge
window.handleNativeEvent = function (eventName, dataJson) {
    const data = JSON.parse(dataJson);
    console.log(`Native event received: ${eventName}`, data);

    // Dispatch custom event for framework consumption
    window.dispatchEvent(
        new CustomEvent('native-event', {
            detail: { name: eventName, data: data }
        })
    );
};

// Call C# methods from JavaScript
async function getDeviceInfo() {
    try {
        const result = await window.HybridWebView
            .InvokeDotNet('GetDeviceInfo');
        document.getElementById('device-info').innerHTML = `
            <p><strong>Platform:</strong> ${result.platform}</p>
            <p><strong>OS:</strong> ${result.osVersion}</p>
            <p><strong>Model:</strong> ${result.deviceModel}</p>
            <p><strong>Battery:</strong>
                ${(result.batteryLevel * 100).toFixed(0)}%</p>
        `;
    } catch (error) {
        console.error('Failed to get device info:', error);
    }
}

// Send raw messages to C# for native operations
function requestNativeShare(text) {
    window.HybridWebView.SendRawMessage(JSON.stringify({
        type: 'share',
        payload: text
    }));
}

// Secure storage wrapper
async function secureGet(key) {
    return await window.HybridWebView
        .InvokeDotNet('ReadFromSecureStorage', [key]);
}

async function secureSet(key, value) {
    await window.HybridWebView
        .InvokeDotNet('WriteToSecureStorage', [key, value]);
}

// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
    getDeviceInfo();
});

BlazorWebView vs HybridWebView: Head-to-Head Comparison

Alright, so let's put these two side by side. The following table summarizes the key differences across the dimensions that actually matter when you're making an architectural decision:

Dimension BlazorWebView HybridWebView
Technology Stack C#, Razor, Blazor component model. Minimal or no JavaScript required. Any web technology: React, Vue, Angular, Svelte, vanilla HTML/JS/CSS.
JavaScript Framework Support Not designed for JS frameworks. You use Blazor components instead. JS interop is available but secondary. Full support for any JavaScript framework or library. The WebView runs your JS app natively.
.NET Integration Depth Deep integration. Blazor components run in the .NET runtime with full access to the BCL, DI container, and platform APIs. Integration via interop bridge. C# methods are invoked from JS through message passing and proxy invocations.
Performance Characteristics UI logic runs natively in .NET (fast computation). Rendering goes through WebView (DOM updates). Initial load includes Blazor framework initialization. UI logic runs in the JavaScript engine. Native calls involve cross-boundary serialization overhead. Startup depends on JS bundle size.
Code Sharing with Web Excellent. Razor components can be shared via Razor Class Libraries between MAUI Hybrid and Blazor Web apps. Moderate. You can share JS/CSS/HTML bundles, but native interop code remains platform-specific.
Learning Curve Low for .NET/Blazor developers. Steep for teams without C# experience. Low for JavaScript developers. Requires learning C# interop patterns for native features.
Debugging Experience Full Visual Studio debugging for C# code. Browser dev tools for CSS/layout inspection. Browser dev tools for JS debugging. C# interop code debuggable in Visual Studio separately.
Best Use Cases New apps by .NET teams; sharing UI with Blazor web apps; apps needing deep .NET integration. Wrapping existing JS web apps; teams with JS expertise; apps needing specific JS libraries or frameworks.

Sharing Code Between Web and Mobile with Razor Class Libraries

One of the most powerful patterns in the Blazor Hybrid ecosystem — and honestly, one of my favorite things about this whole setup — is the three-project architecture. This pattern uses a Razor Class Library (RCL) as a shared UI layer consumed by both a .NET MAUI Hybrid app and a Blazor Web app. The result? A single set of UI components that renders on mobile, desktop, and web platforms.

The Three-Project Architecture

The solution structure looks like this:

Solution: MobileTechLead.App
│
├── MobileTechLead.Shared          (Razor Class Library)
│   ├── Components/
│   │   ├── Pages/
│   │   │   ├── Home.razor
│   │   │   ├── Dashboard.razor
│   │   │   └── Settings.razor
│   │   └── Layout/
│   │       ├── MainLayout.razor
│   │       └── NavMenu.razor
│   ├── Services/
│   │   ├── IAuthenticationService.cs
│   │   ├── IDeviceLocationService.cs
│   │   └── INotificationService.cs
│   ├── Models/
│   │   └── UserProfile.cs
│   ├── wwwroot/
│   │   └── css/
│   │       └── shared-styles.css
│   └── Routes.razor
│
├── MobileTechLead.Maui            (.NET MAUI Hybrid App)
│   ├── Platforms/
│   ├── Services/
│   │   ├── MauiAuthenticationService.cs
│   │   ├── MauiDeviceLocationService.cs
│   │   └── MauiNotificationService.cs
│   ├── MauiProgram.cs
│   └── MainPage.xaml
│
└── MobileTechLead.Web             (Blazor Web App)
    ├── Services/
    │   ├── WebAuthenticationService.cs
    │   ├── WebDeviceLocationService.cs
    │   └── WebNotificationService.cs
    ├── Program.cs
    └── App.razor

Interface Abstraction in the Shared RCL

The key to making this architecture work is defining platform-agnostic interfaces in the shared library that each host project implements differently:

// MobileTechLead.Shared/Services/IAuthenticationService.cs
namespace MobileTechLead.Shared.Services;

public interface IAuthenticationService
{
    Task<AuthResult> SignInAsync(string username, string password);
    Task<AuthResult> SignInWithBiometricsAsync();
    Task SignOutAsync();
    Task<string?> GetAccessTokenAsync();
    Task<bool> IsBiometricAvailableAsync();
}

public record AuthResult(
    bool IsSuccess,
    string? AccessToken = null,
    string? ErrorMessage = null,
    UserProfile? User = null
);
// MobileTechLead.Maui/Services/MauiAuthenticationService.cs
using MobileTechLead.Shared.Services;

namespace MobileTechLead.Maui.Services;

public class MauiAuthenticationService : IAuthenticationService
{
    private readonly HttpClient _httpClient;

    public MauiAuthenticationService(IHttpClientFactory httpClientFactory)
    {
        _httpClient = httpClientFactory.CreateClient("AuthApi");
    }

    public async Task<AuthResult> SignInAsync(
        string username, string password)
    {
        var result = await AuthenticateWithApiAsync(username, password);
        if (result.IsSuccess && result.AccessToken is not null)
        {
            await SecureStorage.Default
                .SetAsync("access_token", result.AccessToken);
        }
        return result;
    }

    public async Task<AuthResult> SignInWithBiometricsAsync()
    {
        var biometricResult = await PlatformBiometricAuth
            .AuthenticateAsync("Sign in to MobileTechLead");

        if (!biometricResult.IsSuccess)
            return new AuthResult(false,
                ErrorMessage: "Biometric authentication failed.");

        var storedToken = await SecureStorage.Default
            .GetAsync("access_token");
        if (storedToken is null)
            return new AuthResult(false,
                ErrorMessage: "No stored credentials found.");

        return new AuthResult(true, AccessToken: storedToken);
    }

    public async Task<bool> IsBiometricAvailableAsync()
    {
        return await PlatformBiometricAuth.IsAvailableAsync();
    }

    public async Task SignOutAsync()
    {
        SecureStorage.Default.Remove("access_token");
        await Task.CompletedTask;
    }

    public async Task<string?> GetAccessTokenAsync()
    {
        return await SecureStorage.Default.GetAsync("access_token");
    }

    private async Task<AuthResult> AuthenticateWithApiAsync(
        string username, string password)
    {
        var response = await _httpClient.PostAsJsonAsync("auth/login",
            new { username, password });
        return new AuthResult(true, AccessToken: "token_value");
    }
}

The shared Razor component consumes the interface without knowing (or caring) which platform it's running on:

@* MobileTechLead.Shared/Components/Pages/Login.razor *@
@page "/login"
@inject IAuthenticationService AuthService
@inject NavigationManager Navigation

<div class="login-container">
    <h3>Sign In</h3>

    <EditForm Model="@loginModel" OnValidSubmit="HandleLogin">
        <DataAnnotationsValidator />

        <div class="form-group">
            <label for="username">Username</label>
            <InputText id="username" class="form-control"
                       @bind-Value="loginModel.Username" />
            <ValidationMessage For="@(() => loginModel.Username)" />
        </div>

        <div class="form-group">
            <label for="password">Password</label>
            <InputText id="password" type="password" class="form-control"
                       @bind-Value="loginModel.Password" />
            <ValidationMessage For="@(() => loginModel.Password)" />
        </div>

        <button type="submit" class="btn btn-primary w-100"
                disabled="@isLoading">
            @(isLoading ? "Signing in..." : "Sign In")
        </button>
    </EditForm>

    @if (isBiometricAvailable)
    {
        <button class="btn btn-outline-secondary w-100 mt-2"
                @onclick="HandleBiometricLogin">
            Sign in with Biometrics
        </button>
    }

    @if (errorMessage is not null)
    {
        <div class="alert alert-danger mt-3">@errorMessage</div>
    }
</div>

@code {
    private LoginModel loginModel = new();
    private bool isLoading;
    private bool isBiometricAvailable;
    private string? errorMessage;

    protected override async Task OnInitializedAsync()
    {
        isBiometricAvailable =
            await AuthService.IsBiometricAvailableAsync();
    }

    private async Task HandleLogin()
    {
        isLoading = true;
        errorMessage = null;

        var result = await AuthService.SignInAsync(
            loginModel.Username, loginModel.Password);

        if (result.IsSuccess)
            Navigation.NavigateTo("/dashboard");
        else
            errorMessage = result.ErrorMessage;

        isLoading = false;
    }

    private async Task HandleBiometricLogin()
    {
        var result = await AuthService.SignInWithBiometricsAsync();

        if (result.IsSuccess)
            Navigation.NavigateTo("/dashboard");
        else
            errorMessage = result.ErrorMessage;
    }

    public class LoginModel
    {
        [Required] public string Username { get; set; } = "";
        [Required] public string Password { get; set; } = "";
    }
}

New in .NET 10: HybridWebView Enhancements

.NET 10 introduces several important enhancements to HybridWebView that address some real pain points developers have been running into. These improvements make HybridWebView significantly more capable for production apps — let's go through the highlights.

WebResourceRequested Event

The WebResourceRequested event lets you intercept and modify web resource requests made by the WebView. This is incredibly useful for injecting authentication headers, serving dynamic content, implementing caching strategies, or redirecting API calls through native code.

// Intercepting web requests in .NET 10
public partial class MainPage : ContentPage
{
    private readonly IAuthenticationService _authService;

    public MainPage(IAuthenticationService authService)
    {
        InitializeComponent();
        _authService = authService;

        hybridWebView.WebResourceRequested += OnWebResourceRequested;
    }

    private async void OnWebResourceRequested(object? sender,
        HybridWebViewWebResourceRequestedEventArgs e)
    {
        // Intercept API calls to inject auth headers
        if (e.Request.Url.StartsWith("https://api.example.com"))
        {
            var token = await _authService.GetAccessTokenAsync();
            if (token is not null)
            {
                e.Request.Headers["Authorization"] = $"Bearer {token}";
            }
        }

        // Serve dynamic content for specific routes
        if (e.Request.Url.Contains("/config/app-settings"))
        {
            var settings = new
            {
                apiBaseUrl = "https://api.example.com",
                environment = "production",
                featureFlags = new
                {
                    darkMode = true,
                    offlineSync = true,
                    pushNotifications = DeviceInfo.Current.Platform
                        != DevicePlatform.WinUI
                }
            };

            e.SetResponse(
                statusCode: 200,
                contentType: "application/json",
                content: JsonSerializer.Serialize(settings)
            );
        }
    }
}

WebViewInitializing and WebViewInitialized Events

These lifecycle events give you control over WebView configuration before and after it's fully initialized. This is critical for setting custom user agents, configuring content security policies, enabling debugging, and adjusting platform-specific WebView settings.

public partial class MainPage : ContentPage
{
    public MainPage()
    {
        InitializeComponent();

        hybridWebView.WebViewInitializing += OnWebViewInitializing;
        hybridWebView.WebViewInitialized += OnWebViewInitialized;
    }

    private void OnWebViewInitializing(object? sender,
        HybridWebViewInitializingEventArgs e)
    {
        e.CustomUserAgent = "MobileTechLead/2.0 " +
            $"({DeviceInfo.Current.Platform}; " +
            $"{DeviceInfo.Current.VersionString})";

#if DEBUG
        e.AllowDeveloperTools = true;
#endif
    }

    private async void OnWebViewInitialized(object? sender,
        HybridWebViewInitializedEventArgs e)
    {
        var userPrefs = await GetUserPreferencesAsync();
        await hybridWebView.InvokeJavaScriptAsync(
            "initializeApp",
            HybridWebViewProxyContext.Default.String,
            [JsonSerializer.Serialize(userPrefs)],
            [HybridWebViewProxyContext.Default.String]);
    }
}

Fire-and-Forget InvokeJavaScriptAsync Overloads

.NET 10 also adds new overloads of InvokeJavaScriptAsync that don't require specifying return type proxy contexts. These are perfect for fire-and-forget scenarios where you need to send data to JavaScript but don't care about a response:

// .NET 10: Simplified fire-and-forget JS invocation
public async Task SendAnalyticsEvent(string eventName,
    Dictionary<string, object> properties)
{
    await hybridWebView.InvokeJavaScriptAsync(
        "trackAnalyticsEvent",
        eventName,
        JsonSerializer.Serialize(properties)
    );
}

public async Task UpdateTheme(string themeName)
{
    await hybridWebView.InvokeJavaScriptAsync(
        "setTheme", themeName);
}

public async Task PushNotificationData(
    IEnumerable<NotificationItem> notifications)
{
    await hybridWebView.InvokeJavaScriptAsync(
        "updateNotifications",
        JsonSerializer.Serialize(notifications)
    );
}

Architecture Patterns for Production Hybrid Apps

Building a production-quality hybrid app takes more than just dropping a WebView on a page. You need well-defined patterns for state management, navigation, authentication, and the interaction between native and web layers. I've seen too many projects skip this step and regret it later.

MVVM with Blazor Hybrid

Blazor Hybrid works naturally with an MVVM-inspired pattern using services and component state. For simpler apps, that's perfectly fine. But for complex applications, introducing view models as injectable services gives you better separation of concerns and testability:

// ViewModels/DashboardViewModel.cs
public class DashboardViewModel : ObservableObject
{
    private readonly IApiClient _apiClient;
    private readonly IConnectivity _connectivity;
    private bool _isLoading;
    private ObservableCollection<DashboardItem> _items = [];

    public DashboardViewModel(IApiClient apiClient,
        IConnectivity connectivity)
    {
        _apiClient = apiClient;
        _connectivity = connectivity;
    }

    public bool IsLoading
    {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }

    public ObservableCollection<DashboardItem> Items
    {
        get => _items;
        set => SetProperty(ref _items, value);
    }

    public async Task LoadDataAsync()
    {
        IsLoading = true;
        try
        {
            if (_connectivity.NetworkAccess == NetworkAccess.Internet)
            {
                var data = await _apiClient
                    .GetAsync<List<DashboardItem>>("dashboard/items");
                Items = new ObservableCollection<DashboardItem>(
                    data ?? []);
            }
            else
            {
                var cached = await _apiClient
                    .GetCachedAsync<List<DashboardItem>>(
                        "dashboard/items");
                Items = new ObservableCollection<DashboardItem>(
                    cached ?? []);
            }
        }
        finally
        {
            IsLoading = false;
        }
    }
}
@* Components/Pages/Dashboard.razor *@
@page "/dashboard"
@inject DashboardViewModel ViewModel
@implements IDisposable

<h3>Dashboard</h3>

@if (ViewModel.IsLoading)
{
    <div class="spinner-border" role="status">
        <span class="visually-hidden">Loading...</span>
    </div>
}
else
{
    <div class="dashboard-grid">
        @foreach (var item in ViewModel.Items)
        {
            <div class="dashboard-card">
                <h4>@item.Title</h4>
                <p class="metric">@item.Value</p>
                <span class="trend @item.TrendCssClass">
                    @item.TrendPercentage%
                </span>
            </div>
        }
    </div>
}

@code {
    protected override async Task OnInitializedAsync()
    {
        ViewModel.PropertyChanged += OnViewModelChanged;
        await ViewModel.LoadDataAsync();
    }

    private void OnViewModelChanged(object? sender,
        PropertyChangedEventArgs e)
    {
        InvokeAsync(StateHasChanged);
    }

    public void Dispose()
    {
        ViewModel.PropertyChanged -= OnViewModelChanged;
    }
}

Navigation Strategies

Hybrid apps face a navigation challenge that's unique to this architecture: you've got MAUI Shell navigation, Blazor Router navigation, and potentially JavaScript framework routing all coexisting in the same app. The recommended approach depends on how much of your app is web-rendered:

Predominantly Blazor UI: Use the Blazor Router as your primary navigation mechanism. MAUI Shell handles only top-level concerns like the title bar and native tabs. All page-to-page navigation occurs within the BlazorWebView via Blazor's NavigationManager.

Mixed Native and Web UI: Use MAUI Shell for top-level navigation between native pages and Blazor-hosted pages. Within each BlazorWebView, the Blazor Router handles internal navigation. Communication between layers happens through services registered in the DI container.

// Hybrid navigation service bridging MAUI Shell and Blazor Router
public class HybridNavigationService : INavigationService
{
    private readonly NavigationManager? _blazorNav;

    public HybridNavigationService(
        NavigationManager? blazorNav = null)
    {
        _blazorNav = blazorNav;
    }

    public async Task NavigateToAsync(string route,
        IDictionary<string, object>? parameters = null)
    {
        if (IsBlazorRoute(route) && _blazorNav is not null)
        {
            var uri = BuildUri(route, parameters);
            _blazorNav.NavigateTo(uri);
        }
        else
        {
            await Shell.Current.GoToAsync(route,
                parameters?.ToDictionary(
                    k => k.Key, v => v.Value) ?? []);
        }
    }

    private static bool IsBlazorRoute(string route) =>
        route.StartsWith("/web/") || route.StartsWith("/dashboard");

    private static string BuildUri(string route,
        IDictionary<string, object>? parameters)
    {
        if (parameters is null || parameters.Count == 0)
            return route;

        var query = string.Join("&",
            parameters.Select(p =>
                $"{p.Key}={Uri.EscapeDataString(p.Value.ToString()!)}"));
        return $"{route}?{query}";
    }
}

State Management Across Native and Web Boundaries

When your app mixes native MAUI pages with web-rendered content, shared state management becomes critical. A centralized state container registered as a singleton in the DI container works well for this:

// Services/AppStateContainer.cs
public class AppStateContainer
{
    private UserProfile? _currentUser;
    private AppTheme _currentTheme = AppTheme.Light;

    public UserProfile? CurrentUser
    {
        get => _currentUser;
        set
        {
            _currentUser = value;
            NotifyStateChanged();
        }
    }

    public AppTheme CurrentTheme
    {
        get => _currentTheme;
        set
        {
            _currentTheme = value;
            NotifyStateChanged();
        }
    }

    public event Action? OnChange;

    private void NotifyStateChanged() => OnChange?.Invoke();
}

Performance Optimization Strategies

Hybrid apps inherently carry the overhead of a WebView, so performance optimization requires attention to both the native and web layers. Here are the strategies that yield the biggest improvements in practice.

Startup Time Optimization

Initial load time is often the most noticeable performance metric — and unfortunately, it's where hybrid apps tend to struggle the most. Several techniques can help:

  • Preload the WebView — On Android, WebView initialization is expensive on first use. Consider initializing a hidden WebView early in the app lifecycle to warm up the underlying engine.
  • Minimize initial payload — For BlazorWebView, keep your initial page lightweight. Defer loading heavy components until after the first render completes.
  • Use ahead-of-time (AOT) compilation — AOT eliminates JIT delays on iOS (where JIT isn't allowed) and improves startup on Android too.
  • Trim unused assemblies — Enable trimming to reduce the assembly payload that the Blazor runtime must load at startup.
<!-- Performance-optimized .csproj settings -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <RunAOTCompilation>true</RunAOTCompilation>
    <PublishTrimmed>true</PublishTrimmed>
    <TrimMode>link</TrimMode>
    <EnableTrimAnalyzer>true</EnableTrimAnalyzer>

    <!-- .NET 10: Compile-time XAML generation -->
    <EnableXamlCompileTimeGeneration>true</EnableXamlCompileTimeGeneration>

    <!-- Optimize image assets -->
    <OptimizeResources>true</OptimizeResources>
</PropertyGroup>

Lazy Loading of Web Content

Not everything needs to be loaded upfront. Implement lazy loading for routes and heavier components to keep that initial render snappy:

@* Shared/Components/LazyRoute.razor *@
@using System.Reflection

@if (_isLoaded)
{
    @ChildContent
}
else
{
    <div class="lazy-loading-placeholder">
        <div class="spinner-border spinner-border-sm"></div>
        <span>Loading module...</span>
    </div>
}

@code {
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public int DelayMilliseconds { get; set; } = 0;

    private bool _isLoaded;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            if (DelayMilliseconds > 0)
                await Task.Delay(DelayMilliseconds);

            _isLoaded = true;
            StateHasChanged();
        }
    }
}

Memory Management with WebViews

WebViews can be memory-hungry, especially on devices with limited resources. These strategies help keep things under control:

  • Dispose WebViews when not visible — If your app has multiple pages and only some use WebViews, consider destroying and recreating the WebView when navigating away and back.
  • Monitor memory pressure — Subscribe to memory warnings from the OS and reduce WebView cache sizes or unload non-essential content in response.
  • Limit DOM complexity — Virtualize long lists rather than rendering thousands of DOM elements. Use Blazor's Virtualize component or a JavaScript virtual scrolling library.
// Memory-aware WebView management
public partial class HybridPage : ContentPage
{
    private BlazorWebView? _webView;

    protected override void OnAppearing()
    {
        base.OnAppearing();

        if (_webView is null)
        {
            _webView = CreateBlazorWebView();
            Content = _webView;
        }
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();

        if (ShouldReleaseWebView())
        {
            Content = new Label { Text = "Loading..." };
            _webView?.DisconnectHandler();
            _webView = null;

            GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized);
        }
    }

    private bool ShouldReleaseWebView()
    {
        return DeviceInfo.Current.DeviceType == DeviceType.Physical;
    }

    private BlazorWebView CreateBlazorWebView()
    {
        var webView = new BlazorWebView
        {
            HostPage = "wwwroot/index.html"
        };
        webView.RootComponents.Add(
            new RootComponent
            {
                Selector = "#app",
                ComponentType = typeof(Routes)
            });
        return webView;
    }
}

Compile-Time XAML Generation in .NET 10

.NET 10 introduces compile-time XAML generation, which eliminates the runtime parsing of XAML files. Even if most of your UI is web-rendered, your hybrid app still uses XAML for the native shell — and this optimization can measurably reduce startup time. The best part? It requires no code changes. Just enable the EnableXamlCompileTimeGeneration MSBuild property and it transparently converts XAML to C# at build time.

Diagnostics and Metrics

You can't optimize what you can't measure. Instrument your hybrid app to track the metrics that matter:

// Services/HybridPerformanceTracker.cs
public class HybridPerformanceTracker
{
    private readonly Stopwatch _appStartStopwatch = new();
    private readonly Dictionary<string, TimeSpan> _milestones = [];

    public void MarkAppStart() => _appStartStopwatch.Start();

    public void RecordMilestone(string name)
    {
        _milestones[name] = _appStartStopwatch.Elapsed;
        Debug.WriteLine(
            $"[Perf] {name}: {_appStartStopwatch.ElapsedMilliseconds}ms");
    }

    public void RecordWebViewReady() =>
        RecordMilestone("WebViewInitialized");

    public void RecordFirstContentfulPaint() =>
        RecordMilestone("FirstContentfulPaint");

    public PerformanceReport GenerateReport() => new()
    {
        TotalStartupTime = _appStartStopwatch.Elapsed,
        Milestones = new Dictionary<string, TimeSpan>(_milestones),
        DevicePlatform = DeviceInfo.Current.Platform.ToString(),
        DeviceModel = DeviceInfo.Current.Model,
        OsVersion = DeviceInfo.Current.VersionString
    };
}

public record PerformanceReport
{
    public TimeSpan TotalStartupTime { get; init; }
    public Dictionary<string, TimeSpan> Milestones { get; init; } = [];
    public string DevicePlatform { get; init; } = "";
    public string DeviceModel { get; init; } = "";
    public string OsVersion { get; init; } = "";
}

Choosing the Right Approach: A Decision Framework

Now that we've taken a thorough look at both BlazorWebView and HybridWebView, here's a practical decision framework to guide your choice. Work through these scenarios in order — the first one that matches your situation is likely your best starting point.

Choose BlazorWebView If...

  • Your team is primarily .NET/C# developers. BlazorWebView lets you build the entire app in C# and Razor without needing JavaScript expertise. The learning curve is minimal if you're already comfortable with Blazor.
  • You have existing Blazor components or a Blazor web app. The shared RCL pattern lets you reuse Blazor components across web and mobile with minimal modification. This is the strongest code-sharing story in the .NET ecosystem right now.
  • You need deep integration with .NET libraries and platform APIs. Blazor components run in the .NET runtime, so they have direct access to the full BCL, NuGet packages, and MAUI platform APIs — no interop boundary to cross.
  • You're building a new application from scratch. Without an existing web frontend to preserve, BlazorWebView offers the most cohesive development experience within .NET.
  • You want a single-language stack. C# everywhere — from data access to business logic to UI rendering — simplifies debugging, testing, and team coordination enormously.

Choose HybridWebView If...

  • You have an existing React, Angular, Vue, or vanilla JS web app that needs to run as a native mobile app. HybridWebView lets you wrap it with minimal changes while adding native capabilities through interop.
  • Your team has strong JavaScript expertise and limited .NET experience. Let JS developers keep working in their preferred ecosystem while C# developers handle native integration.
  • You need a specific JavaScript library that has no Blazor equivalent. Some specialized UI components — complex charting libraries, 3D renderers, rich text editors — only exist in JavaScript.
  • You're migrating a Cordova/Ionic/Capacitor app to .NET MAUI. HybridWebView provides a natural migration path that preserves your web codebase.
  • You want to maintain a single JavaScript codebase that deploys as both a web app and a mobile app, with native features added through MAUI interop rather than a Capacitor/Cordova plugin system.

Consider a Combination If...

  • Your app has distinct modules with different technology needs. For example, a data entry module built with Blazor components and a reporting module that relies on a JavaScript charting library. You can host different WebView types on different pages within the same MAUI Shell.
  • You're planning a phased migration. Start with HybridWebView to wrap an existing JS app, then progressively replace modules with Blazor components over time. This is actually a really pragmatic approach that I've seen work well in practice.

Quick Reference Decision Table

Scenario Recommended Approach
New app, .NET team, maximum code sharing with Blazor web app BlazorWebView + Shared RCL
Existing React/Vue/Angular app needs native wrapper HybridWebView
New app, JS team, .NET backend HybridWebView
Existing Blazor Server/WASM app going mobile BlazorWebView + Shared RCL
Complex app needing JS charting + .NET data processing Both (BlazorWebView primary, HybridWebView for JS modules)
Cordova/Ionic migration to .NET MAUI HybridWebView
Internal enterprise app, .NET shop, rapid development BlazorWebView

Wrapping Up

.NET MAUI's dual approach to hybrid development — BlazorWebView and HybridWebView — reflects a mature understanding that there's no one-size-fits-all solution for cross-platform mobile development. By offering both a Blazor-native path and a JavaScript-agnostic path, Microsoft has made sure .NET MAUI can serve as a native host for virtually any web technology stack.

BlazorWebView shines when your team and codebase are rooted in the .NET ecosystem. The ability to share Razor components between a Blazor web app and a MAUI hybrid app through Razor Class Libraries is genuinely one of the most compelling code-sharing stories available today. Your entire application lives in C#, with the WebView serving purely as a rendering engine.

HybridWebView fills a different but equally important niche: bringing existing JavaScript web apps into the native mobile world without requiring a rewrite. With .NET 10's enhancements — the WebResourceRequested event, lifecycle hooks, and simplified interop — it's matured into a truly production-ready solution.

The patterns we covered here — the three-project shared RCL structure, MVVM with Blazor Hybrid, hybrid navigation strategies, and performance optimization techniques — should give you a solid foundation regardless of which approach you choose.

So which one should you pick? It ultimately comes down to your team's skills, your existing codebase, and your code-sharing requirements. Use the decision framework above as a starting point, and don't be afraid to prototype with both approaches if you're on the fence. And remember — the two aren't mutually exclusive. A single MAUI application can leverage both where it makes architectural sense, and sometimes that's exactly the right call.

About the Author Editorial Team

Our team of expert writers and editors.