WebTransport
A connection to an HTTP/3 server carrying many independent streams and, separately, unreliable datagrams. What it has over a WebSocket is head-of-line blocking, or the lack of it: a lost packet stalls one stream instead of all of them.
@inject Bit.Butil.WebTransport webTransportMDN reference
webtransport over http3 sample server, or your own) to
watch it work.
Chromium and Firefox implement WebTransport, and Safari from 26.4 on - so it comes down to the browser and its version, and IsSupported() is the answer that is actually current.
Bit.Butil.WebTransport webTransport
if (await webTransport.IsSupported())
{
// safe to connect
}Connect resolves once the session is usable, and hands back either the session or the reason there is not one. Incoming data arrives through the callbacks rather than being read from the handle: the browser's reader is a pull loop, so it runs on the JS side and dispatches what it reads.
var result = await webTransport.Connect("https://example.com:4433/echo",
onDatagram: data => Log($"datagram: {data.Length} bytes"),
onStreamData: chunk => Log(chunk.Ended
? $"stream {chunk.StreamId} ended"
: $"stream {chunk.StreamId}: {chunk.Data.Length} bytes"),
onStreamOpened: stream => Log($"the server opened stream {stream.Id}"),
onClosed: info => Log($"closed: {info.CloseCode} {info.Reason} {info.Error}"),
congestionControl: WebTransportCongestionControl.LowLatency);
if (result.Session is null)
{
Log(result.Error);
return;
}
await using var session = result.Session;Unreliable and unordered, like a UDP packet: nothing is retransmitted, nothing is acknowledged, and one larger than the path MTU (about 1200 bytes) is dropped rather than fragmented. Use it for data that is worthless late - position updates, telemetry, media frames.
{
private WebTransportHandle? session; // from webTransport.Connect
private async Task Ping()
{
// Unreliable and unordered by design: true means it was handed to the network, not that it
// arrived. Anything that has to arrive belongs on a stream.
var sent = await session!.SendDatagram(Encoding.UTF8.GetBytes("ping"));
}
}A stream is ordered and reliable. Streams are independent of each other, so a stall on one does not hold up the rest - which is the reason to open several rather than multiplex your own framing over one. Disposing a stream closes its writable half, which is what tells the peer the message is complete.
{
private WebTransportHandle? session; // from webTransport.Connect
private async Task Send()
{
await using var stream = await session!.OpenStream(bidirectional: true);
if (stream is not null)
{
await stream.Write(Encoding.UTF8.GetBytes("hello"));
// what comes back arrives through the session's onStreamData callback
}
}
}certificateHashes - the browser then accepts that exact certificate, and only if
it is short-lived (14 days in Chromium) and uses an ECDSA P-256 key.
API reference
ValueTask<bool> IsSupported()ValueTask<WebTransportConnectResult> Connect(string url, Action<byte[]>? onDatagram = null, Action<WebTransportStreamData>? onStreamData = null, Action<WebTransportStream>? onStreamOpened = null, Action<WebTransportCloseInfo>? onClosed = null, bool allowPooling = false, WebTransportCongestionControl congestionControl = Default, WebTransportCertificateHash[]? certificateHashes = null)ValueTask<WebTransportState> GetState()ValueTask<bool> SendDatagram(byte[] data)ValueTask<WebTransportStream?> OpenStream(bool bidirectional = false)ValueTask Close(int closeCode = 0, string reason = "")ValueTask<bool> Write(byte[] data)string Idrecord WebTransportConnectResult(WebTransportHandle? Session, string Error)record WebTransportStreamData(string StreamId, byte[] Data, bool Ended)record WebTransportCloseInfo(int CloseCode, string Reason, string Error)class WebTransportCertificateHash { string Algorithm; byte[] Value; }enum WebTransportCongestionControl { Default, Throughput, LowLatency }enum WebTransportState { Open, Closed }