Lusamine.WebSocketClient 1.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Lusamine.WebSocketClient --version 1.0.0
                    
NuGet\Install-Package Lusamine.WebSocketClient -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Lusamine.WebSocketClient" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Lusamine.WebSocketClient" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Lusamine.WebSocketClient" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Lusamine.WebSocketClient --version 1.0.0
                    
#r "nuget: Lusamine.WebSocketClient, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Lusamine.WebSocketClient@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Lusamine.WebSocketClient&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Lusamine.WebSocketClient&version=1.0.0
                    
Install as a Cake Tool

Lusamine.WebSocketClient

CI NuGet

A reconnecting WebSocket client for .NET, with no third-party dependencies.

ClientWebSocket gives you a socket. This gives you a connection: one that reconnects on its own, buffers what you send while it is down, notices a dead peer, and reassembles fragmented messages before you see them.

await using var client = new WebSocketClient("wss://example.com/socket");

client.MessageReceived += (_, e) => Console.WriteLine(e.Text);

await client.StartAsync();
await client.SendAsync("hello");

That client already: retries with exponential backoff and jitter, forever; queues sends made while disconnected and flushes them on reconnect; and tears the connection down if the peer stops answering pings.

Contents

Install

dotnet add package Lusamine.WebSocketClient

Targets net10.0 and netstandard2.0.

Dependencies

On net10.0: none. The package has zero PackageReference entries; everything it needs is in the shared framework.

The netstandard2.0 target is the exception, and it has to be: netstandard2.0 predates Span<T>, Channel<T>, IAsyncEnumerable<T> and System.Text.Json. That target pulls the Microsoft-published backports of exactly those in-box-on-modern-.NET types, and nothing else:

Package Supplies
System.Memory Span<T>, Memory<T>, ArrayPool<T>
System.Threading.Channels Channel<T>
Microsoft.Bcl.AsyncInterfaces IAsyncEnumerable<T>, IAsyncDisposable, ValueTask
System.Text.Json the JSON helpers

No logging, DI or options framework is dragged in on either target. See Logging for how to bridge to the logger you already use.

Building a client

Three constructors cover the simple cases:

var client = new WebSocketClient("wss://example.com/socket");
var client = new WebSocketClient(uri);
var client = new WebSocketClient(options);

For anything more, the fluent builder reads better and lets you attach handlers before the client starts, so no early message can arrive unobserved:

await using var client = WebSocketClientBuilder.Create("wss://example.com/socket")
    .WithSubProtocol("json")
    .WithHeader("X-Client", "my-app/1.0")
    .WithExponentialReconnect(initialDelay: TimeSpan.FromMilliseconds(250), maxDelay: TimeSpan.FromSeconds(15))
    .WithHeartbeat(TimeSpan.FromSeconds(15), timeout: TimeSpan.FromSeconds(40))
    .WithMaxMessageSize(8 * 1024 * 1024)
    .OnText(HandleMessage)
    .OnReconnected(Resubscribe)
    .OnError(e => log.Warn(e.Exception, "websocket: {Context}", e.Context))
    .Build();

await client.StartAsync();

BuildAndStartAsync() does the last two lines in one, and disposes the client if the start throws.

Receiving messages

Two ways, and you can use both at once.

Events

Handlers run inline on the receive loop, in order:

client.MessageReceived += (_, e) =>
{
    Console.WriteLine(e.Text);            // decoded and cached
    Console.WriteLine(e.Message.Length);  // raw bytes
};

Because they run on the receive loop, a slow handler slows down receiving. Keep them quick, and do not let them throw: an exception stops the remaining handlers for that event. It is reported on Error rather than swallowed, but the other handlers still miss out.

The Messages stream

A backpressured IAsyncEnumerable:

await foreach (var message in client.Messages)
{
    await ProcessAsync(message);   // taking your time here is fine
}

This is the right choice for slow or async processing: if you stop reading, the client stops reading the socket, and the backpressure propagates down to TCP. The stream ends when the client is stopped or disposed, and survives reconnects untouched.

Two things to know: the buffer is created the first time you touch Messages, so messages that arrive before then only reach MessageReceived; and it is a single-consumer stream, so two readers compete for messages rather than each seeing all of them.

A WebSocketMessage is always a whole application message. Fragmented frames are reassembled first, and Data is a private copy you can hold onto, queue, or hand to another thread.

message.Type          // Text or Binary
message.Data          // ReadOnlyMemory<byte>, owned by this message
message.Text          // decoded once, then cached
message.Deserialize<T>()
message.Deserialize(MyJsonContext.Default.MyType)   // trim- and AOT-safe

Sending messages

await client.SendAsync("a text message");
await client.SendAsync(someBytes);
await client.SendJsonAsync(new { type = "subscribe", channel = "prices" });
await client.SendJsonAsync(payload, MyJsonContext.Default.Payload);   // trim- and AOT-safe

SendAsync completes when the payload has been written to the socket, not when it was queued. If the client is disconnected, the message waits in the outbound queue and the task completes after the reconnect flushes it.

Sends are serialized through a single writer, so concurrent SendAsync calls from many threads are safe and will not interleave frames.

For fire-and-forget, TryPost never throws and never blocks:

if (!client.TryPost("telemetry"))
{
    // the queue is full, or you disabled queueing and the client is down
}

Reconnection

The default policy is exponential backoff with plus or minus 25% jitter, starting at 500 ms, capped at 30 s, retrying forever. The attempt counter resets after every successful connection, so a flaky link never inherits the backoff of an earlier outage.

.WithExponentialReconnect(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(15))
.WithFixedReconnect(TimeSpan.FromSeconds(2), maxAttempts: 10)
.WithoutReconnect()

Anything more specific, you write yourself. Return the delay to wait, or null to stop:

.WithReconnect(ctx => ctx.CloseStatus == WebSocketCloseStatus.PolicyViolation
    ? null                                        // do not fight an auth rejection
    : TimeSpan.FromSeconds(Math.Min(30, ctx.Attempt * 2)))

ReconnectContext carries the attempt number, the disconnect reason, the exception, the peer's close status, and how long the outage has lasted so far.

Reconnecting is where state resync belongs. The client restores the connection, not your subscriptions:

client.Connected += async (_, e) =>
{
    if (e.IsReconnect) await ResubscribeAsync();
};

A clean close frame from the server counts as something to reconnect from by default, since most servers close cleanly on deploy. Turn that off with .ReconnectOnRemoteClose(false) if a server close means "we are done" in your protocol.

Sends across a reconnect

Messages queued while disconnected are flushed, in order, once the connection is back. A message that was mid-flight when the connection broke is a different case, and you choose the semantics:

  • RequeueFailedSends = false (default): the send fails and its SendAsync throws. At-most-once, so no surprise duplicates.
  • RequeueFailedSends = true: the message goes out again on the next connection, keeping its place in the order. At-least-once, which is safe when your protocol is idempotent.

Authentication and per-attempt setup

A static header is enough when the credential never expires:

.WithHeader("Authorization", "Bearer " + token)

It is not enough for tokens that do expire: after a long outage the client would reconnect with a dead credential. Fetch it per attempt instead:

.WithBearerToken(async ct => await tokenService.GetAccessTokenAsync(ct))

For full control over each attempt, such as rotating endpoints, signed URLs or custom headers:

.ConfigureConnection(async (ctx, ct) =>
{
    ctx.Uri = await endpoints.PickAsync(ct);
    ctx.SetHeader("X-Signature", Sign(ctx.Uri));
    ctx.SetBearerToken(await tokens.GetAsync(ct));
})

Throwing in here fails the attempt and feeds the reconnect policy like any other connect failure.

Keep-alive and heartbeats

Two independent mechanisms.

Protocol keep-alive

On by default: a ping every 30 s with a 20 s timeout. On .NET 8 and later a non-zero KeepAliveTimeout gives you real dead-peer detection handled by the runtime, tearing the connection down when a pong does not come back. That is what makes a silently-dropped network cable recoverable.

.WithKeepAlive(TimeSpan.FromSeconds(30), timeout: TimeSpan.FromSeconds(20))

Application heartbeat

Off by default. For servers that expect a ping message rather than a protocol ping, and the way to get dead-peer detection on netstandard2.0, where KeepAliveTimeout does not exist:

.WithHeartbeat(TimeSpan.FromSeconds(15), payload: "ping", timeout: TimeSpan.FromSeconds(45))

Any inbound traffic counts as liveness, so a busy connection never trips the timeout.

Lifecycle and state

Created → Connecting → Connected → Reconnecting → Connecting → Connected → Closing → Closed
                            ↘ Faulted
State Meaning
Created built, not started
Connecting an attempt is in flight
Connected open, messages flowing
Reconnecting dropped; waiting out the backoff delay
Closing graceful close handshake in progress
Closed stopped after a clean end
Faulted the connection broke and the policy declined another attempt

Closed and Faulted are both terminal, and the split is deliberate: Closed means the connection ended by agreement, Faulted means it broke and will not come back. Watch for Faulted if you need to alert on it. StartAsync restarts the client from either.

Starting

By default StartAsync waits for the first attempt and throws WebSocketConnectException if it fails, so a typo in the URL surfaces immediately. If you would rather have the client come up in the background:

.ConnectInBackground()
// ...
await client.StartAsync();                  // returns immediately
await client.WaitForConnectionAsync(ct);    // optional: block until it is up

Stopping

StopAsync flushes what is queued, performs the close handshake (bounded by CloseTimeout), and stops reconnecting.

await client.StopAsync(WebSocketCloseStatus.NormalClosure, "done");

Disposing

await using and DisposeAsync do a best-effort graceful close, then release everything. The synchronous Dispose aborts the socket instead, because blocking on the async path from a synchronous Dispose risks deadlock. Prefer DisposeAsync where you can.

Logging

The package takes no logging dependency. It exposes a two-method interface, and adapting it to whatever you already use is one line:

// Microsoft.Extensions.Logging: the numeric levels line up, so it is a cast
.WithLogger((level, message, ex) => logger.Log((LogLevel)level, ex, "{Message}", message))

// Serilog
.WithLogger((level, message, ex) => Log.Write((LogEventLevel)level, ex, "{Message}", message))

// while developing
.WithConsoleLogging(WebSocketLogLevel.Debug)

Message strings are only built if the level is enabled, so a disabled level costs nothing.

Separately, the Error event reports every failure the client absorbed internally, such as a refused connection, a throwing event handler, or a send that could not be delivered. Each one is tagged with where it happened. Nothing is swallowed silently.

Testing your code

Swap the connector to test without a server:

public sealed class FakeConnector : IWebSocketConnector
{
    public ValueTask<WebSocket> ConnectAsync(WebSocketConnectContext ctx, CancellationToken ct) => ...;
}

var client = WebSocketClientBuilder.Create("wss://ignored/").WithConnector(new FakeConnector()).Build();

The library's own suite lives in tests/ and runs against a real WebSocket server built on TcpListener, so it can do things a normal server will not: reject a handshake, reset a live connection at the TCP layer, stop listening mid-test. It covers reconnection, queue flushing, fragmented reassembly, heartbeat timeouts, graceful close and restart.

dotnet test

# and the same suite against the netstandard2.0 build of the library, which exercises
# the #if NETSTANDARD2_0 compatibility paths rather than the modern ones
dotnet test -p:LibraryTargetFramework=netstandard2.0

The tests use xunit v3. That is a dev-only dependency of the test project, so the shipped package is unaffected and still has zero dependencies on net10.0. Collection parallelization is switched off in AssemblyInfo.cs because several tests assert on timings measured in hundreds of milliseconds; the whole suite runs in about two seconds sequentially.

Options reference

Everything the builder sets is also reachable on WebSocketClientOptions, plus a few things the builder does not surface.

Option Default Notes
Uri / UriProvider none one is required; the provider is re-resolved per attempt
SubProtocols empty offered in preference order
Headers empty static handshake headers
ConfigureConnection none async per-attempt hook
ConfigureWebSocket none escape hatch onto raw ClientWebSocketOptions
Connector ClientWebSocketConnector replace for tests or custom transports
ConnectTimeout 30 s per attempt
CloseTimeout 5 s graceful close budget
KeepAliveInterval 30 s protocol ping interval
KeepAliveTimeout 20 s .NET 8+; real dead-peer detection
Heartbeat off application-level ping
ReceiveBufferSize 16 KiB throughput knob, not a limit
MaxMessageSize unlimited set this for untrusted peers
TextEncoding UTF-8 both directions
JsonSerializerOptions none defaults for the JSON helpers
ReconnectPolicy exponential + jitter ReconnectPolicy.None to disable
ReconnectOnRemoteClose true most servers close cleanly on deploy
ThrowOnInitialConnectFailure true false to connect in the background
QueueWhileDisconnected true otherwise sends throw while down
OutboxCapacity 1024 outbound queue depth
OutboxFullMode Wait dropping modes fail the dropped send
RequeueFailedSends false at-most-once vs at-least-once
MessageStreamCapacity 1024 only allocated if you read Messages
MessageStreamFullMode Wait Wait gives real backpressure
Logger none see Logging
Cookies, Credentials, Proxy, ClientCertificates, UseDefaultCredentials none passed to the handshake
RemoteCertificateValidationCallback none .NET 5+
CollectHttpResponseDetails false .NET 7+; diagnose a rejected upgrade

Options are validated and snapshotted when the client is constructed. Mutating the instance afterwards has no effect; use ConfigureConnection for anything that must change between attempts.

Design notes

One supervision loop. Connect, pump, classify how the connection ended, consult the reconnect policy, wait, repeat. Every failure path funnels into one place, which is why the state machine stays honest.

Three pumps per connection: a receive loop, a send loop and a heartbeat loop. The session ends when the first of them finishes. The single send loop is what makes concurrent SendAsync safe, since WebSocket allows one send at a time, so everything is funnelled through one writer with a completion source per message.

The outbound queue outlives the connection. It belongs to the client, not to a socket, which is what lets queued sends survive a reconnect and lets you queue before ever calling StartAsync.

Closing is queued, not immediate. StopAsync puts the close frame in the outbox behind whatever is already pending, so an orderly shutdown flushes first. A close frame left over from a previous run is stamped with its run id and discarded, so it can never close a freshly restarted connection.

Buffers are pooled. Receives land in a pooled buffer and fragmented messages are assembled in a pooled growable buffer, so only the final message payload is allocated. The net10.0 build is annotated IsAotCompatible and IsTrimmable, and the reflection-based JSON helpers carry the matching RequiresUnreferencedCode and RequiresDynamicCode annotations with source-generator overloads alongside them.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 151 8/11/2026
1.0.0 163 8/11/2026