Lusamine.WebSocketClient
1.0.1
dotnet add package Lusamine.WebSocketClient --version 1.0.1
NuGet\Install-Package Lusamine.WebSocketClient -Version 1.0.1
<PackageReference Include="Lusamine.WebSocketClient" Version="1.0.1" />
<PackageVersion Include="Lusamine.WebSocketClient" Version="1.0.1" />
<PackageReference Include="Lusamine.WebSocketClient" />
paket add Lusamine.WebSocketClient --version 1.0.1
#r "nuget: Lusamine.WebSocketClient, 1.0.1"
#:package Lusamine.WebSocketClient@1.0.1
#addin nuget:?package=Lusamine.WebSocketClient&version=1.0.1
#tool nuget:?package=Lusamine.WebSocketClient&version=1.0.1
Lusamine.WebSocketClient
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 | Versions 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. |
-
.NETStandard 2.0
- Microsoft.Bcl.AsyncInterfaces (>= 9.0.4)
- System.Memory (>= 4.6.3)
- System.Text.Json (>= 9.0.4)
- System.Threading.Channels (>= 9.0.4)
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.