Nodsoft.AspNetCore.SignalR.PostgreSQL provides a drop-in SignalR backplane backed by PostgreSQL LISTEN/NOTIFY — no Redis, no additional infrastructure required.
When you scale your ASP.NET Core application horizontally across multiple server instances, each instance holds its own in-memory map of active SignalR connections. Without a backplane, a message sent on server A cannot reach clients connected to server B. This library solves that by using the PostgreSQL database you already have as a lightweight pub/sub bus.
Each call to a Send* method (e.g. SendAllAsync, SendGroupAsync) serialises the invocation as a JSON payload and issues a pg_notify call on a per-hub PostgreSQL channel. All other server instances receive the notification via a persistent LISTEN connection and dispatch it to their locally tracked connections.
Why PostgreSQL instead of Redis?
- You already run PostgreSQL in most stacks — no extra service to operate.
- LISTEN/NOTIFY is a first-class PostgreSQL feature, stable and well-understood.
- Suitable for low-to-medium throughput real-time workloads where a dedicated message broker is overkill.
Note
For very high throughput or guaranteed message delivery, consider a dedicated message broker (e.g. Redis, RabbitMQ). See Limitations.
dotnet add package Nodsoft.AspNetCore.SignalR.PostgreSQLCall AddPostgreSqlBackplane on your ISignalRServerBuilder in Program.cs (or Startup.cs):
Option A — connection string
builder.Services.AddSignalR()
.AddPostgreSqlBackplane("Host=localhost;Database=myapp;Username=postgres;Password=secret");Option B — NpgsqlDataSource (recommended)
Provide a pre-configured NpgsqlDataSource, for example one registered via the Npgsql Aspire integration or another DI extension. Because the AddPostgreSqlBackplane overloads do not accept a DI factory delegate, use PostConfigure to wire the DI-registered data source into the backplane options after registration:
// Register the data source (e.g. via Npgsql.DependencyInjection or Aspire)
builder.AddNpgsqlDataSource("signalr");
// Register the backplane (connection string acts as a placeholder; PostConfigure overrides it)
builder.Services.AddSignalR()
.AddPostgreSqlBackplane(options => { /* connection string or data source set below */ });
// Override options with the DI-registered NpgsqlDataSource
builder.Services.AddOptions<PostgreSqlBackplaneOptions>()
.PostConfigure<NpgsqlDataSource>((opts, ds) => opts.DataSource = ds);Or, if you have the NpgsqlDataSource instance at hand, pass it directly:
NpgsqlDataSource dataSource = NpgsqlDataSource.Create(connectionString);
builder.Services.AddSignalR()
.AddPostgreSqlBackplane(options => options.DataSource = dataSource);Option C — options delegate
builder.Services.AddSignalR()
.AddPostgreSqlBackplane(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("signalr");
});No changes required to your Hub classes or how you map them:
app.MapHub<ChatHub>("/hubs/chat");That's it. Every server instance that starts with this configuration will participate in the shared PostgreSQL channel for each hub type.
All backplane configuration lives in PostgreSqlBackplaneOptions:
| Property | Type | Description |
|---|---|---|
ConnectionString |
string? |
A standard Npgsql connection string. Used to create an NpgsqlDataSource internally. |
DataSource |
NpgsqlDataSource? |
A pre-configured Npgsql data source. Takes precedence over ConnectionString when both are set. |
Exactly one of ConnectionString or DataSource must be provided. Omitting both throws an InvalidOperationException at startup.
// Pass a pre-built NpgsqlDataSource directly
ISignalRServerBuilder AddPostgreSqlBackplane(this ISignalRServerBuilder builder, NpgsqlDataSource dataSource);
// Pass a connection string
ISignalRServerBuilder AddPostgreSqlBackplane(this ISignalRServerBuilder builder, string connectionString);
// Provide a configuration delegate
ISignalRServerBuilder AddPostgreSqlBackplane(this ISignalRServerBuilder builder, Action<PostgreSqlBackplaneOptions> configureOptions);
┌─────────────────────────┐ ┌─────────────────────────┐
│ Server Instance A │ │ Server Instance B │
│ │ │ │
│ Hub.SendAllAsync(...) │ ─NOTIFY─> │ pg_notify recv │
│ │ │ → dispatch to clients │
│ LISTEN signalr__hub │ <─NOTIFY─ │ Hub.SendAllAsync(...) │
│ → dispatch to clients │ │ │
└────────────┬────────────┘ └─────────────┬───────────┘
│ │
└────────────────┬─────────────────────┘
│
┌───────▼───────┐
│ PostgreSQL │
│ LISTEN/NOTIFY │
└───────────────┘
- On startup,
PostgreSqlHubLifetimeManager<THub>opens a dedicated long-lived PostgreSQL connection and issuesLISTEN "signalr__{hubname}". - On
Send*, the manager serialises the method name, arguments, and routing target into a JSON payload and executesSELECT pg_notify('signalr__{hubname}', @payload). - All listening instances (including the publisher) receive the notification. The publisher skips its own messages by comparing
ServerInstanceId. - Each receiver dispatches the invocation to locally tracked connections that match the routing target.
For a deeper technical description see docs/architecture.md.
The backplane supports every routing target exposed by HubLifetimeManager<THub>:
| SignalR method | Routing |
|---|---|
SendAllAsync |
All connected clients |
SendAllExceptAsync |
All clients except specified connection IDs |
SendConnectionAsync |
A single connection by ID |
SendConnectionsAsync |
Multiple connections by ID |
SendGroupAsync |
All clients in a named group |
SendGroupExceptAsync |
Group clients, excluding specified connection IDs |
SendGroupsAsync |
All clients across multiple named groups |
SendUserAsync |
All connections belonging to a user |
SendUsersAsync |
All connections belonging to multiple users |
| Constraint | Detail |
|---|---|
| Payload size | PostgreSQL NOTIFY payloads are capped at 8 KB. Messages that exceed this limit are dropped with a warning log (LogWarning). Keep hub method arguments small, or consider offloading large data to a shared store (database, blob storage) and sending only a reference. |
| Group membership is local | Each server instance only tracks the groups that its own locally connected clients have joined. A SendGroupAsync from server A reaches only the clients in that group that are connected to server A. This matches the default in-process SignalR behaviour and is sufficient when the load balancer uses sticky sessions. Sticky sessions are strongly recommended. |
| No message persistence | NOTIFY is fire-and-forget. Messages published before a LISTEN connection is established — or during a reconnect gap — are silently lost. |
| Throughput | LISTEN/NOTIFY is suitable for low-to-medium fan-out scenarios. For high-volume, high-frequency message streams consider a dedicated broker. |
- .NET 10 or later
- PostgreSQL 10 or later (LISTEN/NOTIFY has been available since PostgreSQL 9, but Npgsql 10.x requires PG 10+)
- Npgsql 10.x (pulled in transitively; no explicit installation required)
The spike/ directory in this repository contains a full .NET Aspire-hosted sample application demonstrating the backplane in action with a real-time chat interface:
| Project | Role |
|---|---|
Spike.AppHost |
.NET Aspire orchestrator — starts PostgreSQL, the server, and the Blazor client |
Spike.Server |
ASP.NET Core WebAPI + SignalR server with the PostgreSQL backplane |
Spike.Client |
Blazor WebAssembly chat client |
Spike.Common |
Shared contracts (IChatClient, ChatMessage) |
Spike.ServiceDefaults |
Standard Aspire service defaults |
To run the spike locally:
cd spike/Spike.AppHost
dotnet runThe Aspire dashboard will open and show all services. The Blazor client connects to the SignalR server; you can open multiple browser tabs to simulate clients on different logical "server instances" once the server is scaled.
Contributions, bug reports, and feature requests are welcome. Please open an issue or pull request on GitHub.
This project is licensed under the MIT License.
© 2026 Nodsoft Systems — authored by Sakura Akeno Isayeki.