DEV Community

Cover image for How I bypassed Blazor WebAssembly's Virtual DOM using raw WASM pointers
UnitBuilds for UnitBuilds CC

Posted on

How I bypassed Blazor WebAssembly's Virtual DOM using raw WASM pointers

Table Of Contents

The Bottleneck: When WebAssembly UI gets sluggish

If you've ever built a heavy data grid, a real-time telemetry dashboard, or a line-of-business spreadsheet application in Blazor WebAssembly, you've likely hit the rendering wall.

Blazor WASM is incredibly productive, but when thousands of cells change multiple times a second:

  1. Every property change triggers an event.
  2. Every event triggers standard Blazor component cycle overhead (StateHasChanged).
  3. The Virtual DOM (VDOM) diffs the old rendering tree with the new one.
  4. JS-Interop serializes the diffs and updates the browser DOM.

Under high frequency (like 100+ updates per second on 5,000+ fields), this pipeline stutters. The CPU spikes to 100%, garbage collection (GC) triggers pauses, and key-press latency rises.

We decided to see if we could completely bypass Blazor's rendering pipeline while maintaining C# as the single source of truth for business logic.

Our solution is V.A.L.I.D.—a compile-time state-tracking framework that shares raw WebAssembly memory slabs directly with JavaScript.

Step 1: The Unmanaged Memory Slab in C

To avoid heap allocations and GC overhead when managing object states (dirty, error, busy, deleted), we allocate a contiguous block of native memory on the WebAssembly linear heap.

We built a custom bump allocator wrapper called UnmanagedSlab<T> that uses NativeMemory.Alloc:

public unsafe class UnmanagedSlab<T> : IDisposable where T : unmanaged
{
    private T* _pointer;
    private readonly int _length;

    public UnmanagedSlab(int length)
    {
        _length = length;
        // Allocate contiguous memory in the WASM heap
        _pointer = (T*)NativeMemory.Alloc((nuint)length, (nuint)sizeof(T));
    }

    public ref T this[int index] => ref _pointer[index];

    public void* GetUnsafePointer() => _pointer;

    // Dispose calls NativeMemory.Free...
}

Enter fullscreen mode Exit fullscreen mode

Every business object in the application is registered to a fixed slot in this slab. Its state flags are represented as single bits in a System.UInt128 mask register, giving us up to 128 fields tracked per object in O(1) time with 0 heap allocations.

Step 2: The JSExport Bridge

Next, we need to let JavaScript know where this memory block is. We use .NET 8's new JS interop system with [JSExport] inside our WebWorkerBridge class:

public static partial class WebWorkerBridge
{
    private static UnmanagedSlab<System.UInt128>? _stateSlab;

    [JSExport]
    public static unsafe nint GetStatePointer()
    {
        if (_stateSlab == null) return 0;
        return (nint)_stateSlab.GetUnsafePointer();
    }
}
Enter fullscreen mode Exit fullscreen mode

When our Blazor page initializes, we retrieve this pointer and pass it directly to JS as a long integer address:

var ptr = WebWorkerBridge.GetStatePointer();
var len = WebWorkerBridge.GetSlabLength();
await JSRuntime.InvokeVoidAsync("__VAVID_INITIALIZE_INDUSTRIAL_BYPASS__", (long)ptr, len);
Enter fullscreen mode Exit fullscreen mode

Step 3: Surgical DOM Mutation in JS

Once JavaScript receives the memory address, it connects to Mono's WebAssembly linear heap buffer (HEAPU8 array).

First, we compile a surgical map of DOM inputs. Every VavidInput.razor component renders standard metadata data-attributes rather than binding inputs to Blazor:

<input class="vavid-control"
       value="@Value"
       data-vavid-slab-index="@SlabIndex"
       data-vavid-bit="@BitIndex" />

Enter fullscreen mode Exit fullscreen mode

Our JS code parses these elements on startup and runs a high-performance requestAnimationFrame loop.

Because we know the exact byte offsets, JS can read the dirty/error/busy bitmasks directly from the heap array and surgically toggle class names without going through the VDOM:

function syncSurgicalMap() {
    const h = globalThis.Module ? globalThis.Module.HEAPU8 : null;
    const base = statePointer; // the nint address from C#

    for (let i = 0; i < surgicalMap.length; i++) {
        const entry = surgicalMap[i];
        const ptr = base + entry.offset; // slot offset

        // Read dirty and error bit flags directly from WASM memory
        const isDirty = (h[ptr + entry.bitByte] & entry.bitMask) !== 0;
        const hasError = (h[ptr + 32 + entry.bitByte] & entry.bitMask) !== 0;

        // Toggle UI classes instantly in place
        const el = entry.el;
        if (isDirty) el.classList.add('vavid-dirty'); else el.classList.remove('vavid-dirty');
        if (hasError) el.classList.add('vavid-error'); else el.classList.remove('vavid-error');
    }
}
Enter fullscreen mode Exit fullscreen mode

The Performance Payoff

We ran micro-benchmarks comparing this bypass model to standard Blazor VDOM mutation and F# rules engines:

Method Mean Gen 0 / 1000 Allocated Speedup
VALID Slab direct memory write 6.62 ns - 0 B 26.7x
F# Rule Evaluation 15.80 ns - 0 B 10.4x
Blazor VDOM Mutation (Baseline) 172.78 ns 0.0048 40 B Baseline

At 6.6ns per update with 0 bytes allocated, state tracking is running at register-level CPU efficiency.

By avoiding Blazor's entire component render cycle, UI grids can handle millions of visual updates with zero input latency.

Compile-time Generation with Roslyn

Writing manual pointer offsets and bit masks by hand for every class is exhausting. To keep the developer experience clean, we built a Roslyn Source Generator.

All you do is write a partial class:

[ValidObject]
public partial class Invoice
{
    [Required]
    public string CustomerName { get; set; } = "";

    [Range(0, 10000)]
    public decimal Amount { get; set; }
}

Enter fullscreen mode Exit fullscreen mode

The source generator intercepts this at build time and generates the bitmask backing properties, circular undo/redo history, F# record projections, and auto-generates unit/fuzz tests right into your test projects.

Try it yourself!

V.A.L.I.D. is fully open-source. If you want to check out the WASM memory management, review the source generators, or run the Blazor grid benchmark locally, check out the repository:

GitHub logo UnitBuilds-CC / V.A.L.I.D.

V.A.L.I.D. (Vectorized Asynchronous Logic & Intelligent Diagnostics)

V.A.L.I.D. (Vectorized Asynchronous Logic & Intelligent Diagnostics)

Framework Status Performance Diagnostics

V.A.L.I.D. is a high-performance, low-latency business logic framework for .NET. It replaces standard reflection-based change tracking with a compile-time, bitmask-driven, and compiler-integrated architecture.

Built specifically for complex enterprise data management, V.A.L.I.D. ensures surgical precision in synchronization and real-time visibility in the browser.


⚡ Performance Benchmarks

V.A.L.I.D. is engineered for absolute zero-allocation on core operations. Below are the official BenchmarkDotNet results comparing V.A.L.I.D.'s direct memory write speed against other state-management components:

BenchmarkDotNet v0.13.12, Windows 11 (10.0.29591.1000)
.NET SDK 10.0.200-preview.0.26103.119
  [Host]     : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX2
  DefaultJob : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX2
Method Mean Error StdDev Gen0 Allocated
VALID Slab direct memory write 6.619 ns 0.1573 ns 0.2305 ns - - (0 B)
F# Rule Evaluation 15.800 ns 0.3659 ns 0.7308 ns - - (0 B)
F# CRDT Convergence 86.478 ns 1.7367 ns 3.7384 ns 0.0391 328 B
Blazor VDOM Mutation

I’d love to hear your thoughts on this. Is sharing raw pointers with JS in WebAssembly too unsafe for typical business applications, or is the performance payoff worth the trade-off?

Top comments (1)

Collapse
 
unitbuilds profile image
UnitBuilds UnitBuilds CC

Agentic browser MCP (coming soon) utilizing Neo4J graphDb for site-mapping AOM, to allows for shortest path finding for workflow automation and automated testing. Love to hear the community's thoughts on it, so far it's an average of 8x faster and more token efficient than standard browser agents (Puppeteer + DOM scraping + Screenshots).