Skip to content

Hello world

Melih Ercan edited this page Sep 16, 2026 · 1 revision

Hello world

Two peer connections in one process, negotiating with each other and passing a message. About sixty lines, and no signalling server, no second machine, no network and no camera.

It is deliberately the smallest thing that can fail for a real reason. Restoring and compiling prove almost nothing — NuGet falls back to the net10.0 slice when a platform slice is missing, so a package with no Android binding in it compiles perfectly and then has nothing behind the interface. This does not compile-check anything. It runs, and if it prints a message then on your platform:

  • the native half loaded — libwebrtc.aar, WebRTC.xcframework, WebRTC.framework, WebRtcInterop.dll, or the browser's own WebRTC;
  • the binding marshalled SDP in both directions;
  • ICE candidates survived the round trip;
  • DTLS and SCTP completed.

That is the whole negotiation core. Everything else in WebRTC is built on it.

The idea

Signalling gets talked about as though it were part of WebRTC. It is not: WebRTC never says how an offer reaches the other side. A signalling server is just a courier, and if both peers live in the same process you can be the courier yourself, in two event handlers.

sequenceDiagram
    autonumber
    participant C as caller
    participant Y as your code<br/>(the "signalling server")
    participant E as callee

    Y->>C: CreateDataChannel("hello")
    Y->>C: CreateOffer()
    C-->>Y: offer (SDP)
    Y->>C: SetLocalDescription(offer)
    Y->>E: SetRemoteDescription(offer)
    Y->>E: CreateAnswer()
    E-->>Y: answer (SDP)
    Y->>E: SetLocalDescription(answer)
    Y->>C: SetRemoteDescription(answer)

    par candidates, both ways
        C-->>Y: OnIceCandidate
        Y->>E: AddIceCandidate
    and
        E-->>Y: OnIceCandidate
        Y->>C: AddIceCandidate
    end

    Note over C,E: ICE connects · DTLS handshake · SCTP
    E-->>Y: OnDataChannel
    C-->>Y: OnOpen
    Y->>C: Send("hello from the other side")
    C->>E: over the data channel
    E-->>Y: OnMessage
Loading

Replace the middle column with a websocket and you have a real signalling server. That is all one is.

The code

One file, no dependencies beyond the WebRTCme package.

using System.Text;
using Microsoft.JSInterop;
using WebRTCme;

public static class Loopback
{
    // Long enough for DTLS on a slow phone, short enough that a hang is a failure.
    static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);

    /// <param name="jsRuntime">
    /// Blazor only — the browser's JS runtime. The other four platforms take it and ignore it,
    /// so passing it unconditionally is correct everywhere.
    /// </param>
    public static async Task<string> RunAsync(IJSRuntime jsRuntime = null)
    {
        var window = CrossWebRtc.Current.Window(jsRuntime);

        // No STUN server: over loopback, host candidates are all that is needed, and reaching for
        // a public one would make this depend on the internet being up.
        var configuration = new RTCConfiguration
        {
            IceServers = [],
            IceTransportPolicy = RTCIceTransportPolicy.All
        };

        using var caller = window.RTCPeerConnection(configuration);
        using var callee = window.RTCPeerConnection(configuration);

        // Signalling, in two handlers. This is all a signalling server ever does with candidates,
        // minus the JSON and the websocket.
        caller.OnIceCandidate += async (_, e) =>
        {
            if (e.Candidate is not null) await callee.AddIceCandidate(Init(e.Candidate));
        };
        callee.OnIceCandidate += async (_, e) =>
        {
            if (e.Candidate is not null) await caller.AddIceCandidate(Init(e.Candidate));
        };

        var received = new TaskCompletionSource<string>();
        var calleeOpen = new TaskCompletionSource<bool>();

        callee.OnDataChannel += (_, e) =>
        {
            var incoming = e.Channel;
            incoming.OnMessage += (_, m) => received.TrySetResult(
                m.Data as string ?? Encoding.UTF8.GetString((byte[])m.Data));

            // ReadyState first, and this is not defensive padding — waiting only on OnOpen hangs.
            // The channel routinely opens before this handler has subscribed. Same shape as the
            // browser API, and the same fix.
            if (incoming.ReadyState == RTCDataChannelState.Open) calleeOpen.TrySetResult(true);
            else incoming.OnOpen += (_, _) => calleeOpen.TrySetResult(true);
        };

        var channel = caller.CreateDataChannel("hello");
        var callerOpen = new TaskCompletionSource<bool>();
        channel.OnOpen += (_, _) => callerOpen.TrySetResult(true);
        if (channel.ReadyState == RTCDataChannelState.Open) callerOpen.TrySetResult(true);

        // Offer, answer, and the two descriptions swapped over.
        var offer = await caller.CreateOffer();
        await caller.SetLocalDescription(offer);
        await callee.SetRemoteDescription(offer);

        var answer = await callee.CreateAnswer();
        await callee.SetLocalDescription(answer);
        await caller.SetRemoteDescription(answer);

        await callerOpen.Task.WaitAsync(Patience);
        await calleeOpen.Task.WaitAsync(Patience);

        channel.Send("hello from the other side");
        return await received.Task.WaitAsync(Patience);
    }

    /// <summary>The three fields a candidate needs to be re-added at the other end.</summary>
    static RTCIceCandidateInit Init(IRTCIceCandidate candidate) => new()
    {
        Candidate = candidate.Candidate,
        SdpMid = candidate.SdpMid,
        SdpMLineIndex = candidate.SdpMLineIndex
    };
}

That file compiles unchanged against all five slices — net10.0, net10.0-android, net10.0-ios, net10.0-maccatalyst and net10.0-windows10.0.22621.0.

Running it

You need Getting started done first — in particular the script tag on Blazor, and the MAUI version pin. No camera or microphone permission is needed: this touches neither.

Blazor WebAssembly

@page "/hello"
@inject IJSRuntime JsRuntime

<button @onclick="Run">Run loopback</button>
<p>@result</p>

@code {
    string result = "";

    async Task Run()
    {
        try   { result = await Loopback.RunAsync(JsRuntime); }
        catch (Exception ex) { result = ex.ToString(); }
    }
}

.NET MAUI

async void OnRunClicked(object sender, EventArgs e)
{
    try   { ResultLabel.Text = await Loopback.RunAsync(); }
    catch (Exception ex) { ResultLabel.Text = ex.ToString(); }
}

Either way, success looks like this:

hello from the other side

Three things in that code that are not obvious

ReadyState is checked before subscribing to OnOpen. The data channel routinely opens before your handler has subscribed — the native observer is registered when the wrapper is built, on a callback thread, while your handler is posted to the dispatcher. Wait only on the event and you will hang, intermittently, on some platforms and not others. The browser API has the same race and the same fix.

Every wait is bounded. An unbounded await on a native callback that never arrives gives you a frozen app and nothing to read. WaitAsync turns that into a TimeoutException with a stack, which is the difference between a bug you can report and a bug you cannot.

The IJSRuntime argument is passed on every platform. Blazor needs it — it is JSInterop over the browser's WebRTC, and there is no other way for a plain class to reach the runtime. The other four bindings take the argument and ignore it, so one signature works everywhere.

When it does not work

What you see Almost certainly
Nothing happens, no exception, app never starts (Windows) Windows App Runtime missing — Platform prerequisites
JsInterop is not defined, or nothing at all (Blazor) The <script> tag is missing or after blazor.webassembly.js
DllNotFoundException, or TypeInitializationException on CrossWebRtc.Current The native payload did not arrive — the package's platform slice is missing or you are on a fallback slice
Connects, then times out waiting for the channel Real, and worth reporting — open an issue with your platform and the exception

More in Troubleshooting.

From loopback to a real call

The only thing standing between this and a call between two devices is the middle column of that diagram — a transport for the offer, the answer and the candidates. Nothing else changes: CreateOffer, SetLocalDescription, AddIceCandidate are identical.

Two things do get added once the peers are on different machines:

A STUN server, so each peer can discover its public address. IceServers was empty above because loopback needs only host candidates; a real call wants at least one:

IceServers = [new RTCIceServer { Urls = ["stun:stun.l.google.com:19302"] }]

A TURN server, once the two peers are behind NATs that will not let them reach each other directly — which is a large share of real-world pairs, and the usual reason a call "works on my LAN" and then does not. See Connection: Signaling.

You can write that transport yourself, or use the one in the box:

Connection: Signaling Mesh / peer-to-peer, with a SignalR signalling server you deploy yourself. This repository contains the server.
Connection: MediaSoup An SFU, for group calls that a mesh cannot carry. The server is versatica's mediasoup-demo, deployed separately.
Demo apps Both of the above, working, in two complete applications.

Where this sample comes from

It is a reduction of Tests/WebRTCme.DeviceTests.Core/LoopbackScenarios.cs, which is the same negotiation wrapped in a result type and run by CI on Windows, Android, iOS, Mac Catalyst and Blazor on every release. If this page's sample ever stops working, that suite is what should have caught it first — see Testing.

Clone this wiki locally