How to Prove Azure SignalR Delivery Across Instances
Verify cross-instance Azure SignalR delivery with two app instances, split REST triggers, telemetry, and a single long-lived listener.
How to prove your SignalR backend delivers across instances
Goal
Prerequisites
- [ ] An ASP.NET Core app whose hubs are registered with
AddSignalR().AddAzureSignalR(...). - [ ] An Azure SignalR resource (the free tier is enough for the test below).
- [ ] Two app instances reachable at once — two local processes, two dev slots, or two container replicas.
- [ ] Application-level request telemetry that records the serving instance (for example a role/instance dimension on each request).
- [ ] A REST endpoint that publishes to a hub, so a test can trigger a message without holding a second socket open.
Steps
-
1
Configure SignalR
Move the SignalR options out of the
AddAzureSignalRdelegate. Options set inside that delegate can be silently overridden, because the SDK registers its own options setup that runs afterward. Apply them withPostConfigureinstead so they win: -
2
Log efficient options
Log the effective options at startup. Print the values the app is actually running with, not the ones you think you set. This is what reveals that a setting never applied:
-
3
Connect listener
Start two instances and connect one long-lived listener. Open a single client socket to the hub and keep it open for the whole test. This client is your "receiver" — it must keep receiving no matter which instance published.
-
4
Trigger message
Trigger each message with a separate REST call. Fire the publish through the REST endpoint, once per trial. Because each call is independent, the load balancer is free to route it to either instance — which is exactly the cross-instance path you want to prove.
-
5
Track serving instance
Read the serving instance from telemetry, not from a guess. For each trigger, record which instance served the REST call (from the role/instance dimension) and whether the listener received the message. You are looking for triggers that landed on both instances while the single listener received all of them.
-
6
Repeat for each hub
Repeat for every hub type. A test that only covers your main hub misses the others. Re-run it for any parameterised-route hub your clients depend on and for any streaming hub, confirming a multi-update stream completes across instances too.
builder.Services.AddSignalR().AddAzureSignalR();
builder.Services.PostConfigure<ServiceOptions>(o =>
{
o.ServerStickyMode = ServerStickyMode.Required;
o.InitialHubServerConnectionCount = 3; // see Configuration
});
var opts = app.Services.GetRequiredService<IOptions<ServiceOptions>>().Value;
app.Logger.LogInformation("SignalR sticky={Sticky} serverConns={Conns}",
opts.ServerStickyMode, opts.InitialHubServerConnectionCount);
Configuration
| Setting | Recommended | Tradeoff |
|---|---|---|
ServerStickyMode |
Required for streaming/engine hubs |
Keeps a client's messages on one server connection; needed for ordered streams, slightly less balancing freedom. |
| Server connections per hub, per instance | 3 for a two-instance free-tier test |
Lower connection count keeps two instances under the free-tier cap; raise it for production throughput. |
| Number of instances under test | 2 |
Two is the minimum that reproduces cross-instance delivery; the single-instance default can never fail this way. |
Verification
- After startup, the effective-options log line shows sticky mode and the connection count you set — not the defaults.
- Across the trials, the recorded serving instance is split across both instances (for example, some triggers on A and some on B), confirming the load balancer exercised both paths.
- The single listener received every notification: triggers fired equals notifications delivered, with zero service errors.
- The streaming hub's multi-update stream ran to completion with its negotiate calls split across both instances.
Expected shape of a passing run: eight triggers split roughly five-and-three across the two instances, eight of eight notifications delivered, no errors.
Common problems
| Symptom | Likely cause | Fix |
|---|---|---|
| A setting you configured has no effect | Options set in the AddAzureSignalR delegate were overridden by the SDK's own setup |
Move it to PostConfigure and log the effective value |
| Two instances immediately exceed the connection limit | Default server-connection count is too high for the free tier | Lower the per-hub connection count for the test |
| Messages only reach clients on the publishing instance | Hubs are not actually on the Azure SignalR backplane, or you tested with one instance | Confirm AddAzureSignalR is wired and run with two instances |
| Streamed updates arrive out of order or drop | Sticky mode not applied | Set ServerStickyMode.Required and verify via the startup log |
| "Who's online" is wrong after scaling out | Presence stored in a per-process dictionary | Move presence to shared storage (see Related) |
Production checklist
- [ ] Effective SignalR options are logged at startup on every instance.
- [ ] Cross-instance delivery verified with the two-instance, split-trigger test.
- [ ] Sticky mode confirmed for any streaming or ordered hub.
- [ ] Server-connection count sized for production load, not left at the free-tier test value.
- [ ] Any global state (presence, room membership, counters) moved out of in-process memory into shared storage.
- [ ] The verification test kept as a repeatable check to run after future infra changes.
Related / Next steps
- Presence is the classic thing this migration doesn't fix on its own: a per-process dictionary only knows the users connected to that instance, so it stays wrong across a scale-out. Move it to shared storage (Redis or a database) as a follow-up.
- Keep the split-trigger test in your toolbox — it's the fastest way to re-prove delivery after you change instance counts, tiers, or sticky settings.
Ready to Implement This Guide?
Our team can implement these strategies for you, tailored to your specific business needs.
Schedule Consultation