❗❗❗ This SDK is a work in progress, we do not recommend using it in the production ❗❗❗
C# client SDK for Centrifugo and Centrifuge real-time messaging servers.
- ✅ WebSocket and HTTP-streaming transports with automatic fallback
- ✅ Browser-native transports for Blazor WebAssembly (using JS interop)
- ✅ Protobuf binary protocol for efficient communication
- ✅ Automatic command batching for improved network efficiency
- ✅ Automatic reconnection with exponential backoff and full jitter
- ✅ Channel subscriptions with recovery and positioning
- ✅ JWT authentication with automatic token refresh
- ✅ Publication, join/leave events
- ✅ RPC calls
- ✅ Presence and presence stats
- ✅ History API
- ✅ Async/await throughout
- ✅ Thread-safe
- ✅ Comprehensive event system
- ✅ Multi-platform support
The library targets modern .NET frameworks:
- .NET 10.0
- .NET 9.0
- .NET 8.0
- .NET 6.0
- .NET Standard 2.1 (for Unity support)
This means it works on:
- ✅ Server-side: .NET 6/8/9/10
- ✅ Desktop: Windows, macOS, Linux (via modern .NET)
- ✅ Mobile: .NET MAUI (iOS, Android)
- ✅ Web: Blazor WebAssembly, Blazor Server
- ✅ Unity: 2021.2+ with .NET Standard 2.1 support
Note: UWP is not recommended for new projects. Consider using Windows App SDK / WinUI 3 instead, which work with modern .NET targets.
The SDK supports multiple transport protocols with platform-specific implementations for optimal performance:
| Transport | Server/.NET Desktop | Blazor Server | Blazor WASM | Unity Desktop/Mobile | Unity WebGL |
|---|---|---|---|---|---|
| WebSocket | ✅ Native | ✅ Native | ✅ JS Interop* | ✅ Native | |
| HTTP Stream | ✅ Native | ✅ Native | ✅ JS Interop* | ✅ Native | ❌ Not Supported |
*Blazor WebAssembly: Requires IJSRuntime parameter in constructor. The SDK automatically uses browser-native implementations via JavaScript interop for both WebSocket and HTTP Stream transports.
Unity WebGL: Requires a third-party WebSocket plugin. HTTP Stream is not supported in WebGL.
Install via NuGet Package Manager:
dotnet add package Centrifugal.CentrifugeOr via Package Manager Console:
Install-Package Centrifugal.Centrifugeusing Centrifugal.Centrifuge;
// Recommended: Use 'await using' for proper async disposal
await using var client = new CentrifugeClient("ws://localhost:8000/connection/websocket");
// Setup event handlers
client.Connecting += (sender, e) =>
{
Console.WriteLine($"[Client] Connecting: {e.Code} - {e.Reason}");
};
client.Connected += (sender, e) =>
{
Console.WriteLine($"Connected with client ID: {e.ClientId}");
};
client.Disconnected += (sender, e) =>
{
Console.WriteLine($"Disconnected: {e.Code} - {e.Reason}");
};
client.Error += (sender, e) =>
{
Console.WriteLine($"Error: {e.Message}");
};
// Connect to server (non-blocking)
client.Connect();
// Use client methods - they automatically wait for connection
var result = await client.RpcAsync("method", data);
// DisposeAsync is called automatically at the end of the 'await using' block
// It waits for disconnect to complete before releasing resources// Create subscription, may throw Exception subscription already exists in Client's registry.
var subscription = client.NewSubscription("chat");
// Setup subscription event handlers
subscription.Publication += (sender, e) =>
{
var data = Encoding.UTF8.GetString(e.Data.Span);
Console.WriteLine($"Message from {e.Channel}: {data}");
};
subscription.Subscribed += (sender, e) =>
{
Console.WriteLine($"Subscribed to channel, recovered: {e.Recovered}");
};
subscription.Unsubscribed += (sender, e) =>
{
Console.WriteLine($"Unsubscribed: {e.Code} - {e.Reason}");
};
// Subscribe to channel (non-blocking)
subscription.Subscribe();
// Publish to channel - automatically waits for subscription to be ready
var message = Encoding.UTF8.GetBytes("Hello, world!");
await subscription.PublishAsync(message);
// Unsubscribe when done
subscription.Unsubscribe();var options = new CentrifugeClientOptions
{
// Provide initial token
Token = "your-jwt-token",
// Optional: provide token refresh callback
GetToken = async () =>
{
// Fetch new token from your backend
var response = await httpClient.GetAsync("https://your-backend.com/centrifugo/token");
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>();
return tokenResponse.Token;
}
};
var client = new CentrifugeClient("ws://localhost:8000/connection/websocket", options);var subscriptionOptions = new CentrifugeSubscriptionOptions
{
Token = "channel-specific-jwt",
GetToken = async (channel) =>
{
// Fetch channel-specific token from your backend
var response = await httpClient.PostAsJsonAsync(
"https://your-backend.com/centrifugo/subscription_token",
new { channel }
);
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>();
return tokenResponse.Token;
}
};
var subscription = client.NewSubscription("private-channel", subscriptionOptions);
subscription.Subscribe();// Get last 10 messages
var history = await subscription.HistoryAsync(new CentrifugeHistoryOptions
{
Limit = 10
});
foreach (var pub in history.Publications)
{
var data = Encoding.UTF8.GetString(pub.Data.Span);
Console.WriteLine($"Message: {data}");
}// Get all clients in channel
var presence = await subscription.PresenceAsync();
foreach (var client in presence.Clients.Values)
{
Console.WriteLine($"Client: {client.Client}, User: {client.User}");
}
// Get presence stats (counts only)
var stats = await subscription.PresenceStatsAsync();
Console.WriteLine($"Clients: {stats.NumClients}, Users: {stats.NumUsers}");var request = Encoding.UTF8.GetBytes("{\"key\":\"value\"}");
var result = await client.RpcAsync("my_method", request);
var response = Encoding.UTF8.GetString(result.Data.Span);
Console.WriteLine($"RPC result: {response}");For environments where WebSocket might be blocked, use automatic transport fallback:
using Centrifugal.Centrifuge;
// Define transport endpoints to try in order
var transports = new[]
{
new CentrifugeTransportEndpoint(
CentrifugeTransportType.WebSocket,
"ws://localhost:8000/connection/websocket"
),
new CentrifugeTransportEndpoint(
CentrifugeTransportType.HttpStream,
"http://localhost:8000/connection/http_stream"
)
};
// For regular .NET apps
var client = new CentrifugeClient(transports);
// For Blazor WebAssembly (pass IJSRuntime)
var client = new CentrifugeClient(transports, jsRuntime);
// Client will try WebSocket first, then fall back to HTTP Stream if needed
client.Connect();var options = new CentrifugeClientOptions
{
// Connection token and refresh
Token = "your-jwt-token",
GetToken = async () => await FetchTokenAsync(),
// Connection data
Data = Encoding.UTF8.GetBytes("{\"custom\":\"data\"}"),
// Client identification
Name = "my-app",
Version = "1.0.0",
// Reconnection settings
MinReconnectDelay = TimeSpan.FromMilliseconds(500),
MaxReconnectDelay = TimeSpan.FromSeconds(20),
// Timeouts
Timeout = TimeSpan.FromSeconds(5),
MaxServerPingDelay = TimeSpan.FromSeconds(10),
// Logging (pass ILogger for debug output)
Logger = loggerFactory.CreateLogger<CentrifugeClient>(),
// Custom headers (requires Centrifugo v6+)
Headers = new Dictionary<string, string>
{
["X-Custom-Header"] = "value"
}
};
var client = new CentrifugeClient("ws://localhost:8000/connection/websocket", options);var options = new CentrifugeSubscriptionOptions
{
// Enable recovery (if supported by server)
Recoverable = true,
// Enable positioning
Positioned = true,
// Start from known position
Since = new CentrifugeStreamPosition(offset: 100, epoch: "epoch-id")
};
var subscription = client.NewSubscription("chat", options);
subscription.Subscribed += (sender, e) =>
{
if (e.WasRecovering)
{
Console.WriteLine($"Recovery attempted, recovered: {e.Recovered}");
}
};
subscription.Subscribe();try
{
await client.RpcAsync("method", data);
}
catch (CentrifugeUnauthorizedException)
{
Console.WriteLine("Authentication failed");
}
catch (CentrifugeTimeoutException)
{
Console.WriteLine("Operation timed out");
}
catch (CentrifugeException ex)
{
Console.WriteLine($"Centrifuge error: {ex.Code} - {ex.Message}");
Console.WriteLine($"Temporary: {ex.Temporary}");
}The SDK uses binary data (ReadOnlyMemory<byte>) for all payloads, but this doesn't mean you can't use JSON. JSON is simply a UTF-8 encoded byte sequence, so it works seamlessly:
// Sending JSON
var message = new { text = "Hello", timestamp = DateTime.UtcNow };
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(message);
await subscription.PublishAsync(jsonBytes);
// Receiving JSON
subscription.Publication += (sender, e) =>
{
// Option 1: Deserialize directly from bytes (zero-copy, recommended)
var message = JsonSerializer.Deserialize<MyMessage>(e.Data.Span);
// Option 2: Convert to string first
var json = Encoding.UTF8.GetString(e.Data.Span);
Console.WriteLine($"Received: {json}");
};Using ReadOnlyMemory<byte> instead of string provides flexibility - you can work with JSON, MessagePack, Protobuf, or any other serialization format. The .Span property enables zero-copy parsing with modern serializers like System.Text.Json.
You may notice that Connect() and Subscribe() methods are not async and don't return a Task. This is an intentional design choice:
client.Connect(); // Returns void, not Task
subscription.Subscribe(); // Returns void, not TaskWhy?
-
Lifecycle Management: The SDK manages connection and subscription lifecycle internally, including automatic reconnection and resubscription. Calling
Connect()expresses intent ("I want to be connected"), and the SDK maintains that intent across reconnects. -
Automatic Batching: This design enables automatic batching of subscription requests. When you call
Subscribe()on multiple subscriptions before or right afterConnect(), the SDK batches them into a single protocol message:
var sub1 = client.NewSubscription("channel1");
var sub2 = client.NewSubscription("channel2");
var sub3 = client.NewSubscription("channel3");
sub1.Subscribe();
sub2.Subscribe();
sub3.Subscribe();
client.Connect();
// All 3 subscriptions are batched together in one request!- Consistency: This pattern is consistent across all Centrifuge client SDKs (JavaScript, Go, Swift, Dart, etc.).
If you need to wait for connection/subscription:
client.Connect();
await client.ReadyAsync(); // Wait until connected
subscription.Subscribe();
await subscription.ReadyAsync(); // Wait until subscribedThe SDK automatically batches commands where possible for improved network efficiency. This is especially beneficial for HTTP-based transports.
- Commands are automatically batched with a 1ms delay window
- Batches are flushed immediately when they exceed 15KB to prevent oversized requests
- No configuration needed - batching is automatic
When you issue multiple commands without awaiting them immediately, they are queued and sent together in a single batch:
// ❌ Sequential (no batching)
await subscription.PublishAsync(data1);
await subscription.PublishAsync(data2);
await subscription.PresenceAsync();
// Result: 3 separate network requests
// ✅ Batched (recommended)
var task1 = subscription.PublishAsync(data1);
var task2 = subscription.PublishAsync(data2);
var task3 = subscription.PresenceAsync();
await Task.WhenAll(task1, task2, task3);
// Result: 1 network request with all 3 commands!Also, SDK automatically batches subscription requests under the hood (where possible) including re-subscriptions upon reconnect.
See the Blazor example for a complete demonstration of command batching in action.
The library works with Unity 2021.2+ (requires .NET Standard 2.1 support).
Platform Support:
- ✅ Standalone builds (Windows, macOS, Linux): Works out of the box
- ✅ Mobile (iOS, Android): Works out of the box
⚠️ WebGL: Requires a WebSocket plugin that providesSystem.Net.WebSocketssupport, such as:- NativeWebSocket
- WebSocketSharp with Unity WebGL wrapper
Example for Unity (standalone/mobile):
using UnityEngine;
using Centrifugal.Centrifuge;
using System.Threading.Tasks;
public class CentrifugeManager : MonoBehaviour
{
private CentrifugeClient client;
async void Start()
{
client = new CentrifugeClient("ws://localhost:8000/connection/websocket");
client.Connected += (sender, e) =>
{
Debug.Log($"Connected: {e.ClientId}");
};
client.Connect();
}
void OnDestroy()
{
if (client != null)
{
// Dispose() blocks until disconnect completes
client.Dispose();
}
}
}Note: Unity WebGL builds run in the browser but don't have built-in
System.Net.WebSocketssupport. You'll need a third-party WebSocket library that works in Unity WebGL.
The library works in both Blazor Server and Blazor WebAssembly.
Works out of the box - uses standard .NET WebSocket client:
@inject CentrifugeClient Client
@code {
protected override async Task OnInitializedAsync()
{
Client.Publication += async (sender, e) =>
{
// Update UI on message
await InvokeAsync(StateHasChanged);
};
Client.Connect();
}
}Requires IJSRuntime for browser WebSocket access. Register the client in Program.cs:
using Centrifugal.Centrifuge;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Register CentrifugeClient as scoped service
builder.Services.AddScoped(sp =>
{
var jsRuntime = sp.GetRequiredService<IJSRuntime>();
var client = new CentrifugeClient(
"ws://localhost:8000/connection/websocket",
jsRuntime,
new CentrifugeClientOptions
{
// Your options here
}
);
return client;
});
await builder.Build().RunAsync();Then inject and use in your components:
@page "/"
@inject CentrifugeClient Client
@implements IAsyncDisposable
<h3>Messages</h3>
<ul>
@foreach (var msg in messages)
{
<li>@msg</li>
}
</ul>
@code {
private List<string> messages = new();
protected override async Task OnInitializedAsync()
{
Client.Connected += (sender, e) =>
{
messages.Add($"Connected: {e.ClientId}");
InvokeAsync(StateHasChanged);
};
Client.Publication += (sender, e) =>
{
var data = Encoding.UTF8.GetString(e.Data.Span);
messages.Add($"Received: {data}");
InvokeAsync(StateHasChanged);
};
Client.Connect();
}
public async ValueTask DisposeAsync()
{
// DisposeAsync waits for disconnect to complete before releasing resources
await Client.DisposeAsync();
}
}Important: The JavaScript interop file is automatically included as a static asset. If you encounter module loading issues, ensure your index.html has the standard Blazor script tag:
<script src="_framework/blazor.webassembly.js"></script># Clone the repository
git clone https://github.com/centrifugal/centrifuge-csharp.git
cd centrifuge-csharp
# Build the project
dotnet build
# Run tests
dotnet test
# Pack NuGet package
dotnet pack -c ReleaseThis library uses GitHub Actions for automated publishing. To release a new version:
- Update the version in
src/Centrifugal.Centrifuge/Centrifugal.Centrifuge.csproj - Create a git tag:
git tag v1.0.0 - Push the tag:
git push origin v1.0.0 - GitHub Actions will automatically build and publish to NuGet
If you need to publish manually:
- Create a NuGet account at nuget.org
- Get your API key from your account settings
- Build and pack:
dotnet pack -c Release
- Publish:
dotnet nuget push bin/Release/Centrifugal.Centrifuge.*.nupkg --api-key YOUR_API_KEY --source https://api.nuget.org/v3/index.json
The SDK follows the Centrifuge protocol specification and implements:
- Client State Machine: Manages connection lifecycle (Disconnected → Connecting → Connected)
- Subscription State Machine: Manages subscription lifecycle (Unsubscribed → Subscribing → Subscribed)
- Transport Layer: Abstraction over WebSocket and HTTP-streaming
- Exponential Backoff: Smart reconnection with full jitter to avoid thundering herd
- Varint Encoding: Efficient Protobuf message framing
- Event-Driven Architecture: Thread-safe event handling for all state changes
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Make your changes with tests
- Submit a pull request
MIT License - see LICENSE file for details.
- Centrifugo Documentation
- Client SDK Specification
- Centrifuge Protocol
- GitHub Repository
- NuGet Package
- GitHub Issues: Report bugs or request features
- Community Forum: Centrifugal Forum
- Telegram: @centrifugal