Lusamine.WebSocketClient 1.0.1

dotnet add package Lusamine.WebSocketClient --version 1.0.1
                    
NuGet\Install-Package Lusamine.WebSocketClient -Version 1.0.1
                    
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.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Lusamine.WebSocketClient" Version="1.0.1" />
                    
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.1
                    
#r "nuget: Lusamine.WebSocketClient, 1.0.1"
                    
#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.1
                    
#: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.1
                    
Install as a Cake Addin
#tool nuget:?package=Lusamine.WebSocketClient&version=1.0.1
                    
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 with exponential backoff, queues what you send while it is down, notices a dead peer, and reassembles fragmented messages before you see them.

Install

dotnet add package Lusamine.WebSocketClient

Quick start

A complete program: connect, subscribe, print what arrives, survive reconnects, shut down cleanly.

using System.Net.WebSockets;
using Lusamine.WebSocketClient;

const string subscribe = """{"type":"subscribe","channel":"prices"}""";

// Handlers go on before the client starts, so no early message can slip past them.
await using var client = WebSocketClientBuilder.Create("wss://example.com/socket")
    .WithConsoleLogging()
    .OnText(text => Console.WriteLine($"< {text}"))
    .OnError(e => Console.Error.WriteLine($"[{e.Context}] {e.Exception.Message}"))
    .Build();

// Fires on the first connection and on every reconnect, which is exactly when a
// subscription needs re-sending. TryPost queues without blocking and never throws.
client.Connected += (_, _) => client.TryPost(subscribe);

// Throws WebSocketConnectException if the first attempt fails, so a bad URL surfaces
// immediately. After this returns, staying connected is the client's problem, not yours.
await client.StartAsync();

// Run until Ctrl+C.
var quit = new TaskCompletionSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.TrySetResult(); };
await quit.Task;

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

The client reopens the connection. Restoring application state is yours, which is what that Connected handler is for.

Creating a client

new WebSocketClient("wss://example.com/socket");
new WebSocketClient(options);

The builder adds configuration and lets you attach handlers before the client starts. BuildAndStartAsync() builds and starts in one call, disposing the client if the start throws.

await using var client = WebSocketClientBuilder.Create("wss://example.com/socket")
    .WithSubProtocol("json")
    .WithExponentialReconnect(TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(15))
    .WithHeartbeat(TimeSpan.FromSeconds(15), timeout: TimeSpan.FromSeconds(40))
    .WithMaxMessageSize(8 * 1024 * 1024)
    .OnText(HandleMessage)
    .Build();

Receiving

Event handlers run inline on the receive loop, in order. Keep them quick, and do not let them throw: an exception skips the remaining handlers for that event. It is reported on Error rather than swallowed.

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

Messages is a backpressured alternative for slow or async processing: stop reading and the client stops reading the socket. It is single-consumer, and its buffer is created the first time you touch it, so anything that arrived earlier only reached MessageReceived.

await foreach (var message in client.Messages)
    await ProcessAsync(message);

Every WebSocketMessage is a whole application message with fragments already reassembled, and Data is a private copy you can hold onto.

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

Sending

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

client.TryPost("telemetry");   // fire-and-forget: never throws, never blocks

SendAsync completes when the payload reaches the socket, not when it is queued. While disconnected the message waits in the outbound queue and the task completes once the reconnect flushes it. Sends are serialized through a single writer, so concurrent calls from many threads will not interleave frames.

Reconnection

The default is exponential backoff with jitter, from 500 ms up to 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()
.WithReconnect(ctx => ctx.CloseStatus == WebSocketCloseStatus.PolicyViolation
    ? null                                        // stop: do not fight an auth rejection
    : TimeSpan.FromSeconds(Math.Min(30, ctx.Attempt * 2)))

A clean close frame from the server triggers a reconnect by default, since most servers close cleanly on deploy. Turn that off with .ReconnectOnRemoteClose(false).

Messages queued while down are flushed in order. For one that was mid-flight when the connection broke, RequeueFailedSends picks the semantics: at-most-once by default, where the send throws, or at-least-once, where it goes out again on the next connection keeping its place in the order.

Authentication

A static header works when the credential never expires:

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

For tokens that do expire, fetch one per attempt. Otherwise a long outage reconnects with a dead credential.

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

ConfigureConnection gives full control over each attempt, including the URL:

.ConfigureConnection(async (ctx, ct) =>
{
    ctx.Uri = await endpoints.PickAsync(ct);
    ctx.SetBearerToken(await tokens.GetAsync(ct));
})

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

Keep-alive

Protocol pings are on by default, every 30 s with a 20 s timeout. On .NET 8 and later that timeout gives real dead-peer detection from the runtime, which is what makes a silently dropped connection recoverable.

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

For servers that expect a ping message rather than a protocol ping, add an application heartbeat. Any inbound traffic counts as liveness, so a busy connection never trips the timeout.

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

Lifecycle

Created → Connecting → Connected → Reconnecting → Connecting → Connected → Closing → Closed
                            ↘ Faulted

Closed and Faulted are both terminal. Closed means the connection ended by agreement; Faulted means it broke and the policy declined another attempt, so that is the one worth alerting on. StartAsync restarts the client from either.

StartAsync waits for the first attempt and throws WebSocketConnectException if it fails, so a bad URL surfaces immediately. .ConnectInBackground() makes it return straight away instead, with WaitForConnectionAsync(ct) to await the connection.

StopAsync flushes the queue, completes the close handshake and stops reconnecting. await using and DisposeAsync do a best-effort graceful close; the synchronous Dispose aborts the socket instead, since blocking on the async path from a synchronous Dispose risks deadlock.

Logging

No logging dependency. Bridge the two-method interface to whatever you already use:

// 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))

.WithConsoleLogging(WebSocketLogLevel.Debug)

Messages are only formatted if the level is enabled. Separately, the Error event reports every failure the client absorbed internally, each tagged with where it happened.

Testing

Implement IWebSocketConnector to test your code without a server:

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

Options

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, so mutating the instance afterwards has no effect. Use ConfigureConnection for anything that must change between attempts.

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 150 8/11/2026
1.0.0 163 8/11/2026