Chat is a deceptively small feature that turns into one of the hardest system design interviews in a .NET architect loop, because it forces every hard distributed-systems question onto one whiteboard at once: how do you hold millions of long-lived connections, keep messages in the right order when two people type at the same instant, tell a client what it missed while it was offline, and fan a single message out to a group without one slow subscriber blocking everyone else. SignalR gives you the transport and the connection model, but it deliberately doesn't solve message durability, ordering guarantees or presence for you, and those are the architecture decisions the interview is actually testing. A senior candidate can describe hubs and groups; an architect is expected to reason about what happens when a connection drops mid-conversation, how presence stays accurate across multiple devices, and where a message actually becomes durable. The ten questions below cover that ground end to end.

Q1 Design the connection-management layer for a chat service that needs to hold a few million concurrent connections. What actually limits how many connections one server can hold, and how do you scale past it?#

Short answer: Each open connection holds real per-connection state, buffers, and SignalR's own tracking, so the practical ceiling on one instance is usually memory and OS socket or file-descriptor limits before it's raw CPU. Scaling past a single instance's ceiling means running many instances and giving them a way to fan messages out to each other, which is the backplane problem covered in the next question.

SignalR runs on Kestrel, and Kestrel's WebSocket connections are relatively cheap compared to a thread-per-connection model, because I/O is asynchronous and doesn't block a thread per connection. Each connection still holds buffers for the hub protocol, though, and group membership tracking scales with connections multiplied by the groups each one joins, so a chat app with large rooms costs more per connection than one with mostly one-to-one conversations. Horizontal scaling is the only real answer past a single instance's capacity ceiling, and SignalR supports two ways to do it: a Redis backplane, where every instance holds a share of the total connections directly and needs sticky sessions, or the Azure SignalR Service, which moves connection-holding out of your app entirely. The service holds the connections itself, and app servers keep a small, constant number of connections to the service regardless of client count. That second option changes the scaling question from "how many connections can my app tier hold" to "how much message throughput can my app tier produce," which is a materially easier capacity model to reason about and autoscale on.

What interviewers look for: naming the actual resource that runs out first, memory and socket limits rather than reflexively "CPU," and the connection-count-versus-message-throughput reframing that the Azure SignalR Service provides.

Common mistakes: assuming a single, sufficiently powerful server can hold unlimited connections if you just add more CPU; forgetting that group membership itself is state that scales with both connections and groups.

Follow-up questions:

  • How would you estimate the memory cost per connection before load testing?
  • What changes about connection limits if most clients fall back to Long Polling instead of WebSockets?

Q2 Compare a Redis backplane and the Azure SignalR Service for scaling a chat hub across multiple servers. Which would you pick, and what does the choice change operationally?#

Short answer: A Redis backplane uses publish/subscribe: every server subscribes, and a message sent from any one of them is published to Redis and relayed to clients on every server, with each server still holding real connections directly and needing sticky sessions in most configurations. The Azure SignalR Service instead acts as a proxy clients connect to directly, removing the sticky-session requirement and changing what you scale on from connection count to message volume. Pick the Azure SignalR Service by default when hosting on Azure, and a Redis backplane when self-hosting or already running Redis at the needed scale.

Redis backplaneAzure SignalR Service
Client connects toYour app server directlyThe managed service
Sticky sessions requiredUsually yesNo
Scales withConnection countMessage volume
Operational overheadYou run and monitor RedisFully managed

Both mechanisms exist because no single server instance alone knows about connections held by another instance; without one of them, Clients.All or Clients.Group only reaches clients connected to the specific server that handled the call. SQL Server, NCache, Orleans and other message-bus-backed options can also serve as a backplane, for teams that already run one of those and would rather not add Redis. The trade-off that matters most operationally is what you're scaling for: with a Redis backplane, your app still holds every client connection directly, so it must scale out based on connection count even when message volume is low, and it still needs sticky sessions except in a narrow WebSockets-only configuration; the Azure SignalR Service removes both constraints at the cost of an external managed dependency and its own network path.

What interviewers look for: the mechanism difference, publish/subscribe broadcast versus connection proxy, stated precisely, and the sticky-session consequence derived from that mechanism rather than memorized as an independent fact.

Common mistakes: assuming a Redis backplane removes the need for sticky sessions the way the Azure service does.

Follow-up questions:

  • What happens to messages published to Redis if a subscriber instance is momentarily behind on processing?
  • Why might a team deliberately choose a Redis backplane on Azure instead of the managed service?

Q3 How do you guarantee message ordering in a chat conversation when messages can arrive from multiple servers, and a client might reconnect to a different server mid-conversation?#

Short answer: Ordering has to be assigned by something both parties agree on, independent of which server handled a given message: a monotonically increasing, per-conversation sequence number written at the point a message becomes durable, typically by the database. Wall-clock timestamps from different servers can be skewed, and SignalR delivery order is not guaranteed across a reconnect or a backplane hop, so neither can be the source of truth for order.

Write each message with a sequence number scoped to its conversation, generated by the durable store itself, an identity or sequence column, or an equivalent atomic counter, rather than by application code that might race. Clients render messages sorted by that sequence, not by receipt order, because delivery order can genuinely differ from creation order across a fan-out: two servers can each push a message within microseconds of each other, and a client can receive them in either order over the wire even though the store recorded a definite sequence. Most production chat apps render optimistically as messages arrive and re-sort or patch once historical sync confirms the true order, rather than blocking the UI on strict ordering, because true blocking adds visible latency for a benefit users rarely notice in casual conversation. For conversations where strict ordering genuinely matters, such as an audit trail, the client can hold back rendering until sequence gaps are filled, at the cost of that added latency.

What interviewers look for: ordering explicitly separated from delivery, since SignalR gives you delivery, not ordering, and the sequence-number-at-write-time technique volunteered rather than "use timestamps," which a strong candidate should reject unprompted.

Common mistakes: relying on server wall-clock timestamps to order messages across multiple servers; assuming a group send preserves a global ordering guarantee it never made.

Follow-up questions:

  • How would you handle two messages that momentarily contend for the same sequence number under concurrent writes?
  • Does end-to-end encryption change how you can assign or verify ordering?

Q4 Design where and how chat messages are durably stored. What are you optimizing for, and how does that shape the schema?#

Short answer: Store messages in an append-only store partitioned by conversation, since the two dominant access patterns are "give me the last N messages for this conversation" and "give me everything since sequence X," both of which are cheap when one conversation's history is colocated. Treat the real-time SignalR push as a notification that a new message exists, not as the system of record for that message.

Write the message to the durable store first, then push the notification over the hub. If the push happened before the durable write and the write later failed, a client would have seen a message that never actually persisted, a genuine inconsistency no client-side logic can repair. Durable-write-then-push means an unlucky crash between the two loses only the push notification, not the message, and the reconnect-and-sync mechanism covered later recovers from that safely. For storage, a relational store such as SQL Server or PostgreSQL with a per-conversation index works well up to significant scale and gives you transactions; a store like Azure Cosmos DB with conversation ID as the partition key scales further horizontally and fits chat's recent-first, within-a-partition access pattern especially well.

What interviewers look for: the write-then-push ordering justified by what it protects against, an unrecoverable lost message versus a recoverable lost notification, and partitioning by conversation named as the schema decision that follows directly from the actual access pattern.

Common mistakes: pushing over SignalR before the message is durably committed, so a network blip or a crash can show a user a message that later doesn't exist for anyone else; designing a schema around global insertion order instead of per-conversation access.

Follow-up questions:

  • How would you paginate a conversation with millions of historical messages efficiently?
  • Where would message editing or deletion fit into an append-only design?

Q5 How do you build presence, online, away, offline, that stays accurate when a user has multiple devices connected at once, and connections drop and reconnect constantly?#

Short answer: Track presence per connection, not per user, and derive the user-level status by aggregating all of that user's currently open connections, so a user is "online" if any connection is open. That way a phone locking its screen and dropping one connection doesn't flip a user who's still active on their laptop to offline. Clean up per-connection presence deterministically in OnDisconnectedAsync rather than trusting the client to say goodbye.

A naive per-user boolean flag breaks the instant a user has two tabs, or a phone and a laptop, open at once: one disconnecting sets them fully offline even though the other connection is still live. The fix is a store, Redis is a natural fit, with a short time-to-live per connection entry, keyed by connection ID under the user, incremented on connect and removed on disconnect through SignalR's OnConnectedAsync and OnDisconnectedAsync hub overrides. A user's displayed status derives from whether any entries remain for them, and it should be broadcast to interested parties, their contacts or a shared group, only on a zero-to-one or one-to-zero transition, not on every connect, to avoid flooding contacts with a status change on every tab a user happens to open. The time-to-live matters because OnDisconnectedAsync isn't guaranteed to fire promptly for every failure mode; a hard process kill or a network partition can leave a stale entry, so a short, heartbeat-refreshed expiry is the safety net that eventually corrects presence even when the clean-disconnect path doesn't fire.

What interviewers look for: presence modeled per connection with user status derived from aggregation, not a single flag per user, and a time-to-live-based safety net named as necessary because OnDisconnectedAsync is not a guaranteed signal.

Common mistakes: modeling presence as one boolean per user, which multi-device users break immediately; broadcasting a presence change on every connect or disconnect instead of only on a real transition.

Follow-up questions:

  • How would you show "typing" presence, and how does its design differ from online and offline presence?
  • What would you do if the presence store itself became a bottleneck under high reconnect churn?

Q6 Design delivery receipts, sent, delivered and read, for a chat system. What has to be true for a "delivered" receipt to be trustworthy, and how does "read" differ?#

Short answer: "Sent" means the server durably accepted the message. "Delivered" means a specific recipient's client actually received it over an active connection, which the client itself must acknowledge, not something the server can infer from having pushed it. "Read" is a distinct, explicit client signal, the message entered the viewport or the user opened the thread, and should never be inferred from "delivered," because a message can be delivered to a locked phone nobody has looked at yet.

The detail interviewers probe: a hub successfully calling a client method does not prove the client received it. The connection might drop between the server flushing the write and the client's socket actually reading it, especially over Long Polling or during a reconnect window. A trustworthy "delivered" status requires the client to send an explicit acknowledgment back, a hub method call once it has genuinely processed the incoming message, and the server updates delivery state only on receiving that acknowledgment, never on send. For group chats, delivery and read receipts fan out to per-recipient state, a record of message, recipient and status, rather than a single status on the message, since different group members receive and read at different times. Showing an aggregate such as "read by 3 of 8" to the sender is a UI decision layered on top of that per-recipient state, not a different storage model.

What interviewers look for: the explicit-client-acknowledgment requirement for "delivered" stated clearly, since it separates "the server called a send method" from an actually trustworthy guarantee, and per-recipient state named for group scenarios.

Common mistakes: treating a successful server-side send call as proof of delivery; storing read and delivered as a single status on the message instead of per recipient, which breaks the moment a conversation has more than two participants.

Follow-up questions:

  • How would delivery receipts interact with the offline push notification path from the next question?
  • What's the cost, in messages and storage, of per-recipient receipt tracking in a large group?

Q7 A user is offline when a message arrives. Design how they find out, both the push notification and what happens when they come back online.#

Short answer: Route the message through the durable store first, check presence for a live connection, and if there is none, hand off to a platform push notification service to wake the device with a lightweight notification. On reconnect, the client never trusts the push payload as the source of truth; it calls a sync-since-my-last-known-sequence endpoint against the durable store to catch up on everything it missed, push notification or not.

C#
public interface IPushNotificationSender
{
    Task NotifyNewMessageAsync(string userId, ChatPushPayload payload, CancellationToken cancellationToken);
}

public sealed class OfflineNotifier(IPresenceStore presence, IPushNotificationSender push)
{
    public async Task HandleNewMessageAsync(
        ChatMessage message, IEnumerable<string> recipients, CancellationToken cancellationToken)
    {
        foreach (var userId in recipients)
        {
            if (!await presence.HasLiveConnectionAsync(userId, cancellationToken))
            {
                await push.NotifyNewMessageAsync(userId,
                    new ChatPushPayload(message.ConversationId, message.SenderName), cancellationToken);
            }
        }
    }
}

Two concerns get conflated if you're not careful. A push notification's job is only to wake the device or draw attention, not to reliably deliver message content: platform push payloads are frequently size-limited and can be delayed, coalesced by the OS, or simply fail to arrive, so treating a push as guaranteed delivery is a design bug waiting to surface. The reconnect-and-sync path is what actually guarantees the user sees everything: every client persists the highest sequence number it has successfully processed per conversation, and on reconnect, or periodically for a backgrounded app, it calls an endpoint that returns everything newer than that. That's the same mechanism that recovers from a dropped connection during a brief blip, not only from a full offline period. Behind the platform-specific mechanics, a narrow interface like the one above keeps APNs- or FCM-specific payload formats and device token management isolated from the rest of the system, typically behind a service such as Azure Notification Hubs, and easy to swap or mock in tests.

What interviewers look for: push notifications explicitly scoped as a wake-up signal rather than a delivery guarantee, and the sequence-based reconnect sync named as the mechanism that actually guarantees completeness.

Common mistakes: treating a successfully sent push notification as equivalent to message delivery; re-fetching an entire conversation history on every reconnect instead of syncing only what's newer than the client's last known sequence.

Follow-up questions:

  • How would you avoid sending a push notification to a user who's actively viewing the conversation on a different device?
  • If the push provider itself is down, should that block the message from being considered delivered?

Q8 Design group fan-out for a conversation with a large number of members, such as a company-wide announcement channel with tens of thousands of members. What breaks if you naively call the group-send API?#

Short answer: Sending to a group is fine on the transport side at that scale, since the fan-out happens server-side, not as a per-client round trip in your request handler. The naive failure is treating every group member the same regardless of whether they're actually connected right now: doing presence checks, persistence and push-notification logic per member synchronously inside the hub call turns a single message send into tens of thousands of synchronous units of work on the request path.

The fix is separating the fast path from the slow path. A group send, backed by the Redis backplane or the Azure SignalR Service underneath it, handles delivery to currently connected members efficiently, because it's built for exactly this. What doesn't belong inline is per-member bookkeeping, offline push checks, per-recipient delivery-receipt rows, analytics, which should be handed off to a background queue immediately after the group send and processed asynchronously, batched where the downstream system supports it. For truly enormous fan-out, some architectures split further: a real group for currently active viewers, and a separate unread-count or notification mechanism for the rest, rather than trying to push a real-time event to every member who currently has zero open connections, since pushing to nobody is wasted work by definition. Presence-aware fan-out, doing the expensive per-recipient bookkeeping only for members who are actually offline and need a push, is more efficient than treating every member identically regardless of connection state.

What interviewers look for: the distinction between transport-level fan-out, which the backplane already handles well, and per-member bookkeeping, which does not belong on the synchronous request path, plus presence-aware fan-out as the optimization that avoids offline-notification work for members who are actually online.

Common mistakes: doing per-recipient database writes or push calls synchronously inside the hub method that sends the group message, which makes send latency scale with group size.

Follow-up questions:

  • How would you rate-limit a single sender from spamming a very large group?
  • What would you change about this design for a group of a few members versus tens of thousands?

Q9 How do you test and load-test a chat system's real-time path before it reaches production?#

Short answer: Test hub logic with an in-memory WebApplicationFactory and a real HubConnection against the test server, which exercises actual serialization, groups and authentication rather than mocking the hub away. Load-test connection scale and message throughput separately, since they stress different resources: connection count stresses memory and socket limits, while message volume stresses CPU, serialization and the backplane.

C#
public class ChatHubTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task SendMessage_broadcasts_to_other_clients()
    {
        var handler = factory.Server.CreateHandler();
        var connection = new HubConnectionBuilder()
            .WithUrl("http://localhost/hubs/chat", o => o.HttpMessageHandlerFactory = _ => handler)
            .Build();

        var received = new TaskCompletionSource<string>();
        connection.On<string, string>("ReceiveMessage", (_, message) => received.SetResult(message));

        await connection.StartAsync();
        await connection.InvokeAsync("SendMessage", "test-user", "hello");

        Assert.Equal("hello", await received.Task.WaitAsync(TimeSpan.FromSeconds(5)));
    }
}

A load generator for this scenario needs to open real WebSocket or SignalR connections concurrently, not just fire N HTTP requests, since connection establishment and holding is the thing under test. Run two distinct load profiles: a connection-storm test, many clients connecting in a short window, such as after a mobile app update or a regional outage recovery, and a message-storm test, fewer connections but a high message rate, such as a popular live event. Deliberately test failure and reconnect paths too: kill a server instance mid-test and verify clients reconnect and correctly resync through the sequence-based catch-up mechanism, not merely that they reconnect at all.

What interviewers look for: connection-scale and message-throughput load profiles named as distinct tests with different bottlenecks, and reconnect or failover behavior specifically included in the test plan rather than assumed to work because SignalR "handles reconnection."

Common mistakes: load-testing only steady-state message throughput and never simulating a mass-reconnect event, which is often the actual worst case in production.

Follow-up questions:

  • How would you simulate a backplane outage, Redis or the Azure SignalR Service, in a test environment?
  • What metrics would tell you a load test found a real bottleneck versus a limit of the test harness itself?

Q10 Walk through what happens, end to end, when a user sends a message in a group chat, from the client call to every recipient's screen, including the offline case.#

Short answer: The message is authenticated and authorized at the hub, durably written with a conversation-scoped sequence number before anything else happens, fanned out over the backplane to every connected recipient's client, acknowledged individually by each receiving client to produce delivery receipts, and, for any recipient with no live connection, handed to the offline push path. Every step is designed so a client that missed the real-time push entirely still ends up consistent after a reconnect-and-sync call.

Narrated in order: the client calls a hub method with authentication already established through [Authorize] on the hub; the hub verifies the caller is actually a member of the conversation, authorization, not just authentication, before doing anything else; the message is persisted with a new sequence number in the durable store, which is the point at which it becomes real, before any push happens; the hub sends to the conversation's group, which the backplane fans out to every server holding a connection for a member; each connected client renders the message and sends back a delivery acknowledgment, updating per-recipient delivery state; for members with no live connection, the offline notifier checks presence and triggers a platform push; and when any client reconnects, or one that was never disconnected polls after a brief backplane hiccup, it calls the sync-since-sequence endpoint, the actual backstop guaranteeing eventual consistency regardless of what happened in the fan-out and push steps. This end-to-end narration is what interviewers are really listening for across the whole session: every earlier answer is a piece of this one path.

What interviewers look for: the full path narrated in the correct order, especially that persistence happens before fan-out, and that the sync-on-reconnect step is what actually guarantees correctness rather than any individual real-time step.

Common mistakes: describing only the happy real-time path and treating the offline and reconnect cases as a bolt-on afterthought instead of part of the same design.

Follow-up questions:

  • Which single step in this path, if it silently failed, would be hardest to detect in production?
  • How would you add end-to-end tracing across this whole path, from the hub call to the recipient's render?

Quick-Fire Round#

QuestionAnswer
What removes the need for a SignalR backplane at scale?Nothing; every multi-instance deployment needs one to fan messages out.
Does a Redis backplane remove the need for sticky sessions?No, only the Azure SignalR Service, or a single instance, does.
What does the Azure SignalR Service change about what you scale on?Message volume instead of connection count.
What should assign message ordering in a chat conversation?A per-conversation sequence number written at persistence time, not wall-clock time or delivery order.
Should a message be pushed before or after it's durably stored?After; pushing before risks showing a message that never actually persists.
Is presence per user or per connection?Per connection, aggregated up to a user-level status.
Does a successful send call prove delivery?No; a trustworthy "delivered" status needs an explicit client acknowledgment.
What guarantees a client catches up after being offline?A sync-since-last-sequence call on reconnect, not the push notification itself.
What shouldn't run synchronously inside a large group's fan-out call?Per-member bookkeeping such as push checks and receipt rows; queue them instead.

How to Prepare#

  • Be able to narrate the full send-to-receive path end to end, in the correct order, including the offline and reconnect cases; it's the single best way to show system-level thinking in this interview.
  • Know precisely why persistence has to happen before the SignalR push, not after.
  • Practice the Redis-backplane-versus-Azure-SignalR-Service comparison as a mechanism difference, publish/subscribe versus connection proxy, not a memorized feature list.
  • Have a concrete answer for presence with multiple devices; per-connection tracking aggregated to a user status is the detail that trips up most candidates.
  • Rehearse why a push notification is a wake-up signal, not a delivery guarantee, and what actually closes that gap.