-
Notifications
You must be signed in to change notification settings - Fork 2
Payload Strategies
PostgreSQL caps a NOTIFY payload at 8KB. That’s fine for most SignalR messages, but if you ever send something larger (think: large JSON blobs, batched updates, binary payloads) you need an alternate route. A payload strategy decides how to package hub messages into Postgres notifications and how to turn a notification back into the original payload on the receiving side. Strategies are responsible for:
- Emitting a
NOTIFY(orpg_notify) for a given channel and payload. - Resolving an incoming notification payload into the raw message bytes SignalR should process.
- (Optionally) persisting and cleaning up payloads if they don’t ride inside the notification.
Below are the built-in strategies and how to pick between them.
The event strategy (EventPayloadStrategy) base64-encodes the message and sends it directly in the notification payload.
Pros
- Simple, fast, minimal moving parts.
- No extra tables, storage, or cleanup.
Cons
- Hard limit at ~8KB; anything larger will fail because Postgres rejects oversized
NOTIFYpayloads. - No throttling or spillover for bursts of large messages.
Use when your messages are small and you value simplicity. If you know you’ll occasionally exceed 8KB, switch to a strategy that can spill to storage.
The table strategy (TablePayloadStrategy) stores the payload in a table and only places a reference (id:<number>) in the notification. This solves the 8KB limit by moving the heavy bytes into a table and letting NOTIFY carry a tiny pointer.
Why the options? Different workloads need different trade-offs:
- If you only rarely exceed 8KB, it’s wasteful to hit the table for every message.
- If you want predictable performance and payload size, you might choose to always store in the table.
- If you care about disk growth, you need a cleanup story (built-in timer or external job).
- If you already have a table and a better cleanup mechanism (e.g.,
pg_cron), you don’t want the built-in table.
Modes
-
StorageMode = Auto(default): small payloads ride in the notification; large payloads go to the table. Good for mixed workloads that are mostly small but sometimes spike. -
StorageMode = Always: every payload goes to the table; notifications only carry an ID. Good for consistent sizing and when you want to avoid edge-case failures entirely.
builder.Services
.AddSignalR()
.AddPostgresBackplane(dataSource)
.AddBackplaneTablePayloadStrategy(options =>
{
options.StorageMode = PostgresBackplanePayloadTableStorage.Auto; // or Always
options.SchemaName = "backplane";
options.TableName = "payloads";
options.AutomaticCleanup = true;
options.AutomaticCleanupTtlMs = 300000; // min age to keep (ms)
options.AutomaticCleanupIntervalMs = 21600000; // sweep interval (ms)
});
var app = builder.Build();
await app.InitializePostgresBackplanePayloadTableAsync(); // creates the built-in table shape if missing- On send:
- If
Autoand the payload is below the threshold, it uses the event strategy (inline base64). - Otherwise it inserts the payload into the table and sends a notification like
id:<number>.
- If
- On receive:
- If the payload looks like
id:<number>, it loads the bytes from the table. - Otherwise it treats the payload as inline (for small messages in
Automode).
- If the payload looks like
If AutomaticCleanup is enabled, a System.Timers.Timer in each server periodically deletes rows older than AutomaticCleanupTtlMs.
Pros
- Zero external dependencies; works out of the box.
Cons
- Best-effort; if all servers are down, nothing runs.
- Multiple servers may sweep in parallel.
- Timer-based cleanup inside app processes isn’t as robust as a DB job.
If you need tighter guarantees, disable AutomaticCleanup and schedule cleanup yourself (e.g., pg_cron, a Kubernetes CronJob, or your preferred scheduler). That lets you control retention windows, schedule, and monitoring.
Built-in
- Call
InitializePostgresBackplanePayloadTableAsync()to create the default schema/table/indexes. - Good for “just make it work” with minimal setup.
Custom
- Point
SchemaName/TableNameat your table. - Table requirements:
id BIGSERIAL PRIMARY KEYpayload BYTEA NOT NULLcreated_at TIMESTAMPTZ NOT NULL DEFAULT now()- Index on
created_at(recommended) for cleanup.
When to use a custom table
- You already have a schema/table for this data.
- You want to manage lifecycle externally (e.g.,
pg_croncleanup, different TTLs per environment). - You want to control indexes/partitioning/retention yourself.
Why roll your own? You might want to:
- Store payloads somewhere else (S3, Redis, another DB).
- Compress/encrypt payloads in a custom way.
- Use a different table shape or ID scheme.
- Apply application-specific retention rules.
Implement IPostgresBackplanePayloadStrategy:
-
Task NotifyAsync(string channelName, byte[] message, CancellationToken ct = default): must issue theNOTIFY(orpg_notify) and decide how to store/encode the payload. -
byte[] ResolveNotificationPayload(NpgsqlNotificationEventArgs eventArgs): synchronously turn an incoming notification into the original payload bytes.
The strategy should be registered in DI after the backplane is configured:
builder.Services.AddSignalR().AddPostgresBackplane(dataSource);
builder.Services.AddSingleton<IPostgresBackplanePayloadStrategy, MyPayloadStrategy>();The strategy does not need to be registered as a singleton, however the hub lifetime manager is a singleton (per hub-type), which will hold an instance of the strategy. If you have multiple hub types you might want to register your strategy as transient to get different instances per hub type.
Practical examples:
-
Compression/encryption: Compress/encrypt before
NOTIFY; decrypt/decompress on receive. -
External store: Write payload to S3/Redis, send an object key in
NOTIFY, fetch on receive. -
Custom table/cleanup: Write to your own table/partition scheme and let
pg_cronhandle retention.