loading
Warning:
WebRTC does not connect anyone by itself Two peers cannot find each other without a channel that already works: an offer, an answer and a stream of ICE candidates have to be carried between them by something else - your own server over WebSocket, a shared document, a QR code. That exchange is signalling, it is not part of the API, and it is the part people are surprised to have to build. The sections below play both peers in one tab and hand the messages straight across, which is exactly what a real signalling channel would do more slowly - and Two real windows does the same thing between two actual browser windows, with BroadcastChannel standing in for the server.

Support check

IsSupported

RTCPeerConnection is in every current engine. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.

C#
@inject Bit.Butil.WebRtc webRtc

var supported = await webRtc.IsSupported();
Live sample
support check output
Results will appear here when you interact with the samples.

The whole handshake

CreatePeerConnection / CreateOffer / SetLocalDescription / SetRemoteDescription / AddIceCandidate

One side offers, the other answers, and both send every ICE candidate they discover to the other as it arrives - waiting for them all is slower and gains nothing. Everything a peer will send has to be added before the offer that describes it: a data channel created afterwards means another offer and answer. Watch the states: signalling goes stable → have-local-offer → stable, and the connection goes new → connecting → connected.

C#
var a = await webRtc.CreatePeerConnection(
    iceServers: [new RtcIceServer { Urls = ["stun:stun.l.google.com:19302"] }],
    onIceCandidate: json => SendToTheOtherPeer(json),
    onConnectionState: state => InvokeAsync(StateHasChanged));

var channel = await a!.CreateDataChannel("chat");   // before the offer

var offer = await a.CreateOffer();
await a.SetLocalDescription(offer!);
// ... send offer.Sdp to the other peer, get an answer back ...
await a.SetRemoteDescription(answer);
Live sample
Peer A Not created.
Peer B Not created.
handshake output
Results will appear here when you interact with the samples.

A socket to another browser

CreateDataChannel / SendText / SendBytes / Listen

The reason to use a data channel rather than a WebSocket is what it can give up: an unordered, non-retransmitting channel delivers what is current rather than what is complete, which no TCP-based transport can offer at all. Nothing can be sent until the channel opens, so subscribe before the handshake - a channel that opens before anything is listening still opens, and that callback has already been missed.

Razor
@code {
    // The peer connection from the section above. A data channel has to be created before the offer
    // is made: it is part of what the offer describes.
    private PeerConnectionHandle? a;
    private RtcDataChannelHandle? channel;

    private async Task Chat()
    {
        channel = await a!.CreateDataChannel("chat", ordered: false, maxRetransmits: 0);
        channel!.Listen(
            onMessage: m => InvokeAsync(StateHasChanged),
            onOpen: () => { /* now it can send */ },
            onClose: () => { });

        await channel.SendText("hello, other browser");
        await channel.SendBytes([1, 2, 3, 4]);
    }
}
Live sample
Peers A Not created. · B Not created.
data channel output
Results will appear here when you interact with the samples.

Two real windows

CreatePeerConnection / CreateOffer / CreateAnswer / AddIceCandidate + BroadcastChannel

The sections above are a loopback: both peers live in this tab, so nothing is ever signalled. Here the two peers are in two different browser windows and the offer, the answer and every ICE candidate travel between them over BroadcastChannel - which reaches every same-origin window in this browser and is the smallest thing that can stand in for a signalling server. Open this page in a second window, press Host in one and Join in the other, and the chat below is a real peer connection. Two notes that bite every first WebRTC implementation are handled here: the joiner announces itself so the host makes its offer to somebody who is listening (an offer posted before the second window exists is simply lost), and candidates that arrive before the remote description is set are queued rather than added, because addIceCandidate rejects them until there is a description to attach them to.

Razor
@inject Bit.Butil.WebRtc webRtc
@inject Bit.Butil.BroadcastChannel broadcastChannel

@code {
    private ButilSubscription? _signalling;
    private PeerConnectionHandle? _peer;
    private RtcDataChannelHandle? _channel;

    private async Task Connect()
    {
        // Signalling: anything that reaches the other side will do - WebRTC does not specify it.
        // Here, other windows of this browser.
        _signalling = await broadcastChannel.Subscribe("butil-webrtc-demo", OnSignal);

        _peer = await webRtc.CreatePeerConnection(
            iceServers: [new RtcIceServer { Urls = ["stun:stun.l.google.com:19302"] }],
            onIceCandidate: json => InvokeAsync(() => broadcastChannel.Post("butil-webrtc-demo",
                                                        new { type = "candidate", candidate = json }).AsTask()),
            onRemoteChannel: channel => { _channel = channel; channel.Listen(onMessage: Show); });
    }

    // Host, once the other window says it is there:
    private async Task Offer()
    {
        var offer = await _peer!.CreateOffer();
        await _peer.SetLocalDescription(offer!);
        await broadcastChannel.Post("butil-webrtc-demo", new { type = "offer", sdp = offer!.Sdp });
    }

    // Joiner, on that offer:
    private async Task Answer(string sdp)
    {
        await _peer!.SetRemoteDescription(new RtcSessionDescription("offer", sdp, null));
        var answer = await _peer.CreateAnswer();
        await _peer.SetLocalDescription(answer!);
        await broadcastChannel.Post("butil-webrtc-demo", new { type = "answer", sdp = answer!.Sdp });
    }
}
Live sample
This window not in the room - idle
two-window output
Results will appear here when you interact with the samples.

What is actually happening on the wire

GetStats

Everything the browser knows: bitrates, packet loss, round-trip time, the codec in use, which candidate pair won. The members differ per stat type, so each entry carries its type and a dictionary rather than pretending to a record shape. The two worth looking at first are the nominated candidate-pair - the path in use, and its round-trip time - and the inbound-rtp entries, which say what is actually arriving.

Razor
@code {
    private PeerConnectionHandle? a;   // a connected peer connection

    private async Task Inspect()
    {
        var stats = await a!.GetStats();

        // The nominated candidate pair is the route actually carrying traffic, out of everything ICE
        // tried.
        var path = stats.FirstOrDefault(s => s.Type == "candidate-pair" && s.Values.GetValueOrDefault("nominated") == "true");
        var rtt = path?.Values.GetValueOrDefault("currentRoundTripTime");
    }
}
Live sample
Peers A Not created. · B Not created.
stats output
Results will appear here when you interact with the samples.

Sending the camera

AddTracksFrom / AttachRemoteMedia

MediaDevices acquires the stream, AddTracksFrom puts it on the connection, and the other side shows it. Add the tracks before the offer: the offer describes what will be sent, so adding a camera afterwards means negotiating again - which is why this button reconnects. Needs a secure context and the camera permission, so it prompts.

Razor
@inject Bit.Butil.MediaDevices mediaDevices

<video @ref="_remoteVideo" autoplay playsinline></video>

@code {
    private ElementReference _remoteVideo;
    private PeerConnectionHandle? a;   // the sending side
    private PeerConnectionHandle? b;   // the receiving side

    private async Task SendCamera()
    {
        var stream = await mediaDevices.GetUserMedia(audio: false, video: true);

        // Before the offer: the tracks are part of what the offer describes, so adding them after it
        // needs a second round of negotiation.
        await a!.AddTracksFrom(stream!);

        // on the other side:
        await b!.AttachRemoteMedia(_remoteVideo);
    }
}
Live sample
Peers A Not created. · B Not created.
Peer A is sending
Peer B is receiving
media output
Results will appear here when you interact with the samples.
Note:
STUN, TURN, and the bill Two peers on the same network connect with no servers at all. Anything across the internet usually needs a STUN server to discover its own public address - cheap, stateless, and why public ones exist. Somewhere between a tenth and a fifth of connections cannot find a direct path at all and need a TURN server to relay the whole conversation, which costs bandwidth and needs credentials. That is the infrastructure behind a WebRTC feature, and it is worth knowing before promising one.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes RTCPeerConnection. Returns default (false) during prerender/SSR instead of throwing.
CreatePeerConnection
ValueTask<PeerConnectionHandle?> CreatePeerConnection(RtcIceServer[]? iceServers = null, Action<string?>? onIceCandidate = null, Action<string>? onConnectionState = null, Action<string>? onTrack = null, Action<RtcDataChannelHandle>? onRemoteChannel = null)
Creates a connection. onIceCandidate fires per candidate and once with null when gathering ends; onRemoteChannel fires when the other side opens a channel - subscribe to that channel before the callback returns, since it is already open by then.
PeerConnectionHandle.CreateDataChannel
ValueTask<RtcDataChannelHandle?> CreateDataChannel(string label, bool ordered = true, int maxRetransmits = -1)
Opens a channel - before the offer. maxRetransmits of 0 is the lowest-latency, least-guaranteed mode, which TCP cannot do at all.
PeerConnectionHandle.CreateOffer / CreateAnswer
ValueTask<RtcSessionDescription?> CreateOffer(), CreateAnswer()
The text to send the other peer. Carries an Error rather than throwing when it cannot be made.
PeerConnectionHandle.SetLocalDescription / SetRemoteDescription
ValueTask<string?> SetLocalDescription(RtcSessionDescription), SetRemoteDescription(RtcSessionDescription)
Applies this side's own description - which starts ICE gathering - or the peer's. Null on success.
PeerConnectionHandle.AddIceCandidate
ValueTask<string?> AddIceCandidate(string? candidateJson)
Adds a candidate the peer discovered, or null for its end-of-gathering signal.
PeerConnectionHandle.AddTracksFrom / AttachRemoteMedia
ValueTask<bool> AddTracksFrom(MediaStreamHandle stream), AttachRemoteMedia(ElementReference videoOrAudioElement)
Sends a MediaDevices stream to the peer, and shows what the peer is sending. Attaching is safe before any track arrives.
PeerConnectionHandle.GetConnectionState / GetIceConnectionState / GetSignalingState
ValueTask<string> ...()
The overall state, the ICE agent's own, and where the offer/answer exchange has got to.
PeerConnectionHandle.GetStats
ValueTask<RtcStat[]> GetStats()
Everything the browser knows about the connection, flattened to strings.
RtcDataChannelHandle.Listen
void Listen(Action<ButilMessage>? onMessage = null, Action? onOpen = null, Action? onClose = null)
Attaches the callbacks. For a channel you created, call it before the handshake completes or the open has already been missed. For one the peer created, call it inside onRemoteChannel before that callback returns - the channel's events are held for exactly that long.
RtcDataChannelHandle.SendText / SendBytes
ValueTask<bool> SendText(string text), SendBytes(byte[] data)
False when the channel is not open. Incoming binary arrives as bytes rather than a Blob.
RtcDataChannelHandle.GetState / GetBufferedAmount
ValueTask<string> GetState(), ValueTask<long> GetBufferedAmount()
connecting / open / closing / closed, and the same back-pressure signal a WebSocket gives.
RtcIceServer
class { string[] Urls; string? Username; string? Credential; }
A STUN or TURN server. TURN credentials reach the browser, so issue short-lived per-session ones.
RtcStat
record (string Id, string Type, Dictionary<string, string> Values)
One stats entry. Values differ per Type, so there is no honest record shape for them.
An unhandled error has occurred. Reload 🗙