Recursive AI

Run ONNX in the browser with C# and WebGPU

OneModel is a free, open-source neural-network runtime for .NET from Recursive AI that runs ONNX models and trains models. MIT or Apache-2.0, no native dependencies.

Run C# ONNX inference inside a browser with WebGPU. Device creation and GPU runs must be asynchronous.

Terminal
dotnet add package RecursiveAI.OneModel.Gpu

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using RecursiveAI.OneModel.Graph;
using RecursiveAI.OneModel.Gpu;
using RecursiveAI.OneModel.Tensors;
 
async Task<float[]> Run(byte[] modelBytes)
{
    OneModelGpu.Register();
    await OneModelGpu.PrepareDeviceAsync();
    using var session = Session.Load(modelBytes,
        new() { Device = ComputeDevice.Gpu });
    using var x = Tensor.FromSpan<float>(
        [1, 2, 3, 4], 2, 2);
    var inputs = new Dictionary<string, Tensor>
        { ["x"] = x };
    using var y = await OneModelGpu.RunAsync(
        session, inputs);
    return y["y"].ToArray<float>();
}

The method returns the four values shown here; the full browser program displays them on the page.

Text
7, 10, 15, 22

Unzip the example, run dotnet workload install wasm-tools, then dotnet run and open the app URL printed in the terminal.

square.onnx is in the zip: y = MatMul(x, x), with float32 [2, 2] input and output. The JavaScript loader below passes its downloaded bytes to the C# method; the input is [[1, 2], [3, 4]].

The full program

The browser calls the exported Demo.Run method. This run used our own headless Chrome with WebGPU on the NVIDIA RTX 4070; the backend reported the adapter as nvidia lovelace.

C#
using System.Runtime.InteropServices.JavaScript;
using System.Runtime.Versioning;
using RecursiveAI.OneModel.Graph;
using RecursiveAI.OneModel.Gpu;
using RecursiveAI.OneModel.Tensors;
 
// JavaScript calls Demo.Run after startup.
return;
 
[SupportedOSPlatform("browser")]
public static partial class Demo
{
    [JSExport]
    public static async Task<string> Run(
        byte[] modelBytes)
    {
        OneModelGpu.Register();
        await OneModelGpu.PrepareDeviceAsync();
        using var session = Session.Load(modelBytes,
            new ModelOptions
            {
                Device = ComputeDevice.Gpu
            });
        using var input = Tensor.FromSpan<float>(
            [1, 2, 3, 4], 2, 2);
        var inputs =
            new Dictionary<string, Tensor>
            { ["x"] = input };
        using var outputs =
            await OneModelGpu.RunAsync(
                session, inputs);
        var info = OneModelGpu.GetInfo(session);
        var placement =
            session.GetDiagnostics(inputs);
        return string.Join(", ",
            outputs["y"].ToArray<float>())
            + $"\nAdapter: {info?.Adapter}"
            + $"\nAPI: {info?.Api}"
            + "\nGPU nodes: "
            + placement.NodesOnDevice;
    }
}

Install the browser tooling with dotnet workload install wasm-tools, then dotnet run and open the app URL printed in the terminal in a WebGPU-capable browser. The page displays:

Text
7, 10, 15, 22
Adapter: nvidia lovelace
API: WebGpu
GPU nodes: 1

wwwroot/index.html

HTML
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>ONNX with WebGPU</title>
<pre id="output">Loading…</pre>
<script type="module" src="./main.mjs"></script></html>

wwwroot/main.mjs

JAVASCRIPT
import { dotnet } from './_framework/dotnet.js';
import * as gpu from './_content/RecursiveAI.OneGpu.WebGpu/onegpu.js';
try {
  const runtime = await dotnet.create();
  runtime.setModuleImports('onegpu', gpu);
  const exports = await runtime.getAssemblyExports(
    runtime.getConfig().mainAssemblyName);
  const response = await fetch('./square.onnx');
  if (!response.ok) throw new Error(`Model fetch: ${response.status}`);
  const bytes = new Uint8Array(await response.arrayBuffer());
  const result = await exports.Demo.Run(bytes);
  document.getElementById('output').textContent = result;
  window.__result = JSON.stringify({ output: result });
} catch (error) {
  document.getElementById('output').textContent = String(error);
  window.__result = JSON.stringify({ error: String(error) });
}

The project uses Microsoft.NET.Sdk.WebAssembly, RuntimeIdentifier set to browser-wasm, and AllowUnsafeBlocks enabled for JavaScript interop. These settings are included in the download. For deployment, publish with dotnet publish -c Release and serve the published wwwroot over HTTPS.

Without preparation, GPU session creation throws DeviceUnsupported; a blocking Run that reaches the browser GPU throws DeviceFailure.

Variations

To require GPU placement, replace the options passed to Session.Load in Demo.Run with:

C#
new ModelOptions
{
    Device = ComputeDevice.Gpu,
    RequireGpu = true
}

For this graph the displayed result stays 7, 10, 15, 22, with GPU nodes: 1.

The short answer

Build a .NET browser-wasm app with RecursiveAI.OneModel.Gpu. Register the backend, await PrepareDeviceAsync before creating each GPU session, load the ONNX bytes, and await OneModelGpu.RunAsync. The JavaScript module must register the package’s onegpu imports with the .NET runtime before calling the exported C# method.

The snippet at the top is the inference method. The full project adds the browser entry point, JavaScript export and file loader. The ONNX file is a matrix-product graph supplied in the download, not a service endpoint. Its named input is x, its named output is y, and both tensors contain four float32 values arranged in two rows and two columns.

Can ONNX use GPU in the browser?

Yes. OneModel’s browser path executes supported operators through the WebGPU backend while the C# runtime runs as WebAssembly. This is a different execution environment from a desktop .NET program using Vulkan or Metal. The model still loads into a Session, but creating the graphics device and waiting for GPU work must be asynchronous.

The browser GPU documentation says to prepare one device per GPU session. The session takes ownership of that prepared device and disposes it when the session ends. If your application creates a second session, prepare another device first; one preparation is not a process-wide replacement for this step.

Do not replace RunAsync with blocking Session.Run for a GPU session in the browser. When a run reaches a device region, that blocking call throws SessionException with kind DeviceFailure. A model that runs entirely on the CPU can still use Run, but that is not the GPU inference shown here. Awaited runs of one session take turns.

The JavaScript loader has two separate jobs. First it installs the onegpu module imports used by the managed WebGPU backend. Then it fetches square.onnx and passes the byte array to the exported method. Checking the fetch status prevents an HTML error page or missing file from being mistaken for valid model bytes. Keep that error path when substituting your own model URL.

Which browsers are compatible with WebGPU?

Use a browser and graphics-driver combination that exposes WebGPU. General browser support is tracked by MDN’s WebGPU API compatibility table; support in a browser family does not establish that every machine has a usable adapter. Our example ran in headless Chrome on the NVIDIA RTX 4070. Its backend adapter label is shown exactly as returned, rather than renamed to the desktop device string.

For a deployed page, use HTTPS: MDN marks WebGPU as a secure-context API. Serve the published app, its framework assets, the backend JavaScript module and model together. The source download is not itself a deployment bundle; dotnet publish -c Release produces the wwwroot directory to serve. During development, dotnet run hosts the browser app and prints its app URL.

If preparation cannot find a usable adapter, OneModel throws DeviceUnsupported. Treat that as a capability failure, not an empty prediction. You may offer an explicit CPU path if your application supports one, but a GPU request does not silently become a CPU-only session. Within a working GPU session, individual unsupported operators can still stay on the CPU unless RequireGpu is enabled.

Model bytes and prepared weights both occupy WebAssembly linear memory. Loading a model successfully on the desktop therefore does not establish that it fits in the browser. The tiny graph here keeps the example focused on device setup, imports and asynchronous execution. For a real model, inspect placement for representative inputs and account for the model and intermediate tensors rather than treating a successful four-element demonstration as a capacity claim.

All onemodel recipes