WebSockets allow a client and server to exchange data over a persistent connection. They are commonly used for chat applications, live dashboards, notifications, collaborative editing, and real-time monitoring.
Unlike a typical HTTP request, a WebSocket connection can remain open for a long time. During that period, the network may disconnect, a server may restart, or a proxy may close an idle connection. The client must decide when to reconnect, how to handle messages that were not delivered, and when to stop retrying.
These behaviors are difficult to validate with ordinary unit tests. A connection may work perfectly on a developer's machine but fail under network interruptions or during a production deployment.
.NET developers can test WebSocket behavior by combining unit tests, integration tests, controlled connection failures, and explicit reconnection logic. This article explains how to test those scenarios and avoid common problems such as duplicate messages, endless retries, and reconnect loops.
Understand WebSocket Connection States
The .NET ClientWebSocket class represents a client-side WebSocket connection. Its state indicates whether the connection is connecting, open, closing, closed, or has never been connected.
The main states are:
None: The WebSocket has not started connecting.
Connecting: A connection attempt is in progress.
Open: The connection is established.
CloseSent: The client has sent a close request.
CloseReceived: The client has received a close request.
Closed: The connection has been closed.
Aborted: The connection has been aborted.
A successful connection does not guarantee that it will remain available. Network failures can occur after the handshake, and a client must not assume that an open connection will stay open indefinitely.
Before writing tests, define the behavior your application should follow when a connection fails. For example, should it reconnect automatically? How many attempts should it make? Should it resume a subscription or retrieve missed data after reconnecting?
These decisions determine what the tests need to verify.
Step 1: Create a Basic WebSocket Client
Start with a small client that connects to a WebSocket endpoint, sends a message, and receives a response.
using System.Net.WebSockets;
using System.Text;
public static class WebSocketClientExample
{
public static async Task RunAsync(
Uri endpoint,
CancellationToken cancellationToken)
{
using var socket = new ClientWebSocket();
await socket.ConnectAsync(
endpoint,
cancellationToken);
var message = Encoding.UTF8.GetBytes("Hello server");
await socket.SendAsync(
message,
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
var buffer = new byte[4096];
var result = await socket.ReceiveAsync(
buffer,
cancellationToken);
if (result.MessageType == WebSocketMessageType.Text)
{
var response = Encoding.UTF8.GetString(
buffer,
0,
result.Count);
Console.WriteLine(response);
}
}
}
This example demonstrates the basic connection lifecycle. It assumes that the server sends a response that fits into the buffer and arrives in a single WebSocket message.
In production code, do not assume that one receive operation returns a complete message. A WebSocket message can arrive in multiple frames, so the client should continue receiving until EndOfMessage is true.
Also, coordinate sends and receives carefully. ClientWebSocket supports one concurrent send and one concurrent receive, but an application should not start multiple simultaneous sends or multiple simultaneous receives on the same connection.
Step 2: Test the WebSocket Message Lifecycle
A useful integration test should verify more than whether the handshake succeeds. It should check that the client can send a message and receive the expected response.
The following example uses xUnit and assumes that a WebSocket server is already running at a test endpoint.
using System.Net.WebSockets;
using System.Text;
using Xunit;
public class WebSocketIntegrationTests
{
[Fact]
public async Task Client_Can_Send_And_Receive_Message()
{
using var socket = new ClientWebSocket();
using var timeout =
new CancellationTokenSource(
TimeSpan.FromSeconds(10));
await socket.ConnectAsync(
new Uri("ws://localhost:5000/ws"),
timeout.Token);
var outgoing = Encoding.UTF8.GetBytes("ping");
await socket.SendAsync(
outgoing,
WebSocketMessageType.Text,
true,
timeout.Token);
var buffer = new byte[1024];
var result = await socket.ReceiveAsync(
buffer,
timeout.Token);
Assert.Equal(
WebSocketMessageType.Text,
result.MessageType);
Assert.True(result.EndOfMessage);
var response = Encoding.UTF8.GetString(
buffer,
0,
result.Count);
Assert.Equal("pong", response);
}
}
The server must implement the expected ping and pong behavior for this test to pass. The endpoint and response are examples, not a built-in .NET WebSocket service.
The cancellation token places a limit on the test duration. Without a timeout, a test can wait indefinitely if the server accepts the connection but never responds.
This test checks a successful exchange. Additional tests should cover connection rejection, server shutdown, message fragmentation, and unexpected disconnections.
Step 3: Handle Messages That Arrive in Multiple Frames
A WebSocket message can be fragmented across multiple frames. The ReceiveAsync method may return part of a message with EndOfMessage set to false.
A receive loop should collect data until the complete message arrives or the connection closes.
using System.Net.WebSockets;
using System.Text;
public static async Task<string?> ReceiveTextAsync(
ClientWebSocket socket,
CancellationToken cancellationToken)
{
using var stream = new MemoryStream();
var buffer = new byte[4096];
while (true)
{
var result = await socket.ReceiveAsync(
buffer,
cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
if (result.MessageType != WebSocketMessageType.Text)
{
throw new InvalidOperationException(
"Expected a text message.");
}
stream.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
return Encoding.UTF8.GetString(
stream.ToArray());
}
}
}
This helper returns null when the server initiates a close handshake. Otherwise, it collects the text message before decoding it.
For production use, impose a maximum message size. Without a limit, a peer could send an unexpectedly large message and cause excessive memory consumption.
Tests should include fragmented messages to verify that the application does not mistake an individual frame for a complete message.
Step 4: Implement Reconnection with a Retry Limit
A WebSocket client should not reconnect indefinitely without considering the reason for the failure.
A connection attempt can fail because of a temporary network problem, but it can also fail because the server rejects authentication or the endpoint is incorrect. Repeating the same request without changing anything will not necessarily solve the problem.
A simple retry policy can use a maximum number of attempts and increasing delays.
using System.Net.WebSockets;
public static async Task<bool> ConnectWithRetryAsync(
ClientWebSocket socket,
Uri endpoint,
CancellationToken cancellationToken)
{
const int maxAttempts = 5;
for (var attempt = 1;
attempt <= maxAttempts;
attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await socket.ConnectAsync(
endpoint,
cancellationToken);
return true;
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (WebSocketException)
when (attempt < maxAttempts)
{
var delaySeconds = Math.Min(
Math.Pow(2, attempt - 1),
16);
await Task.Delay(
TimeSpan.FromSeconds(delaySeconds),
cancellationToken);
}
}
return false;
}
This example illustrates bounded retry behavior. The delay increases between attempts, reducing the rate of repeated connection requests.
There is an important limitation: a ClientWebSocket instance should not be treated as a reusable connection after a failed or completed connection attempt. A production reconnection manager should create a new ClientWebSocket for each connection attempt and dispose of the previous instance.
The retry policy should also distinguish transient failures from permanent ones. Authentication failures, invalid endpoints, and unsupported protocols generally require corrective action rather than repeated retries.
For production systems, consider exponential backoff with random jitter. Jitter helps prevent many clients from reconnecting at exactly the same time after a shared outage.
Step 5: Test Reconnection After a Network Failure
A reconnection test should deliberately interrupt a working connection and verify that the client establishes a new one.
One approach is to run a test server that can accept connections and then close them on demand. The test can verify that the client detects the closure, creates a new WebSocket instance, and reconnects successfully.
A useful test scenario is:
Start the test server.
Connect the client.
Exchange a message successfully.
Instruct the server to close the connection.
Verify that the client detects the closure.
Allow the reconnection policy to run.
Confirm that a new connection is established.
Send another message and verify the response.
Avoid testing only the final connection state. The client may appear connected while failing to restore subscriptions or application-level state.
A real integration test should verify the behavior the application actually needs after reconnection.
For example, a notification client may need to resubscribe to a channel. A dashboard may need to request the latest data because updates sent during the disconnection were not received.
Step 6: Test Cancellation During Connection Attempts
Network operations can take time, and an application may be shutting down while a connection attempt is still running.
Cancellation should stop the connection attempt when the supplied token is cancelled.
[Fact]
public async Task ConnectionAttempt_Stops_When_Cancelled()
{
using var socket = new ClientWebSocket();
using var cancellation =
new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => socket.ConnectAsync(
new Uri("ws://localhost:5000/ws"),
cancellation.Token));
}
This test uses an already-cancelled token. It verifies that the operation respects cancellation, but it does not simulate a connection being interrupted midway through a real network handshake.
For a more realistic test, use a controllable test server or a test environment where connection establishment can be delayed. Cancel the token while the connection is pending, then verify that the client exits promptly and releases resources.
Do not confuse a cancellation requested by your application with a network failure. They may require different logging and retry behavior. If the application is shutting down, automatically reconnecting after cancellation is usually undesirable.
Step 7: Test Server Restarts and Unavailable Endpoints
WebSocket connections can fail during application deployments, server restarts, load balancer changes, or temporary network outages.
A useful test suite should cover these situations separately.
For a server restart, establish a connection, stop the server, and verify that the client detects the failure. Restart the server and check that the client reconnects within the configured retry policy.
For an unavailable endpoint, verify that the client stops after the retry limit and reports a useful failure rather than continuing forever.
For a rejected connection, test the appropriate response to authentication or authorization failure. Do not assume that every failed handshake should trigger another attempt.
When testing through a reverse proxy or load balancer, also verify the configured WebSocket support and idle connection timeout. A connection may be closed by an intermediary even when neither application explicitly requests closure.
Step 8: Prevent Duplicate Messages After Reconnection
Reconnecting restores a connection, but it does not automatically guarantee message delivery.
Suppose a client sends a command and the server processes it, but the connection drops before the response reaches the client. The client cannot know from the disconnection alone whether the command was processed.
If it retries the command, the server may execute the operation twice.
For operations that change data, use application-level safeguards such as idempotency keys or unique message identifiers. The server can record processed identifiers and avoid executing the same operation more than once.
For example, a message might contain an operation identifier:
{
"messageId": "order-update-12345",
"type": "UpdateOrder",
"orderId": 12345
}
The server should validate the message and apply a suitable deduplication policy before processing it. The identifier format is illustrative; production systems need a durable and correctly scoped way to recognize repeated operations.
If the application must recover messages missed during disconnection, design an explicit resume or replay mechanism. This may involve sequence numbers, persisted events, or requesting the latest state from the server.
A successful reconnection by itself does not restore messages that were sent while the client was offline.
Common Mistakes to Avoid
Reusing a failed ClientWebSocket instance
Create a new instance for each new connection attempt. Dispose of the previous instance and ensure that its resources are released.
Retrying every failure
Use bounded retries and classify errors. Permanent configuration or authentication problems require a different response from temporary network failures.
Ignoring cancellation
Pass cancellation tokens to connection, send, receive, and delay operations. Ensure that shutdown stops the reconnect loop rather than triggering another attempt.
Assuming one receive call returns a complete message
Continue reading until EndOfMessage is true. Also handle close messages and enforce a maximum message size.
Assuming reconnection guarantees message delivery
Define how the application handles missed messages, duplicate commands, and interrupted operations. Use acknowledgments, sequence numbers, or idempotency where appropriate.
Best Practices for Production Applications
Keep connection management separate from application message processing. Use bounded retry policies, exponential backoff with jitter, and cancellation tokens to control connection lifetimes.
Log connection attempts, close status, retry counts, and meaningful failure details without logging sensitive message content or credentials.
Test normal operation, fragmented messages, server restarts, handshake failures, cancellation, and reconnection. Use a controllable test server to reproduce failures instead of depending only on unpredictable network conditions.
Finally, make the reconnection policy match the application's requirements. A chat client, a financial command processor, and a monitoring dashboard may need different behavior when a connection drops.
Conclusion
Reliable WebSocket applications need more than a successful connection handshake. They must handle interrupted connections, cancelled operations, server restarts, and messages that may not have been delivered.
In .NET, ClientWebSocket provides the core connection APIs, while cancellation tokens, bounded retries, and integration tests help control failure behavior.
Test the complete lifecycle, create a fresh client for each connection attempt, and define how the application restores its state after reconnecting. These practices make WebSocket clients more predictable under real network conditions and reduce the risk of stuck operations or duplicate processing.
Join the conversation! Your thoughts help the community grow.