Lusamine.WebSocketClient
1.0.0
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
<PackageReference Include="Lusamine.WebSocketClient" Version="1.0.0" />
<PackageVersion Include="Lusamine.WebSocketClient" Version="1.0.0" />
<PackageReference Include="Lusamine.WebSocketClient" />
paket add Lusamine.WebSocketClient --version 1.0.0
#r "nuget: Lusamine.WebSocketClient, 1.0.0"
#:package Lusamine.WebSocketClient@1.0.0
#addin nuget:?package=Lusamine.WebSocketClient&version=1.0.0
#tool nuget:?package=Lusamine.WebSocketClient&version=1.0.0
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, 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
- Dependencies
- Building a client
- Receiving messages
- Sending messages
- Reconnection
- Authentication and per-attempt setup
- Keep-alive and heartbeats
- Lifecycle and state
- Logging
- Testing your code
- Options reference
- Design notes
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 itsSendAsyncthrows. 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 | 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.