Real-Time Chat with SignalR
Author(s)
- Sanket Mal
Last Updated Date
2025-12-12
SRS References
Version History
| Version | Date | Changes | Author |
|---|---|---|---|
| 1.0 | 2025-11-19 | Implement SignalR into existing Chat system (Hybrid Architecture : SignalR + HTTP) | Sanket Mal |
Feature Overview
Objective:
Implement a hybrid real-time communication system using SignalR + HTTP that supports instant messaging between dealers and customers with read receipts, typing indicators, and real-time conversation updates.
Scope:
- Real-time message delivery with SignalR WebSocket connections
- HTTP endpoints for reliable message sending and read operations
- Typing indicators and read receipts
- Multi-user and multi-device support
- Privacy-aware customer data handling (contact info hidden until dealer wins)
- Automatic fallback to SSE/Long Polling if WebSocket unavailable
Dependencies:
- ASP.NET Core SignalR
- Microsoft.AspNetCore.SignalR.Protocols.MessagePack
- Existing JWT authentication system
- PostgreSQL database
- MassTransit for inter-service communication
Requirements
- Real-time message delivery between dealers and customers
- Support for multiple dealer users per dealer entity
- Support for multiple devices per user (web + mobile)
- Real-time read receipts (blue ticks)
- Real-time typing indicators
- Backward compatibility with existing HTTP chat APIs
- Secure authentication using JWT tokens
- Privacy enforcement (customer contact info only visible to winning dealers)
- Message delivery confirmation
- Automatic reconnection on network interruption
Design Specifications
UI/UX Design
- Chat Window:
- Instant message appearance
- Read receipts (double blue tick when read, single grey tick when delivered)
- "User is typing..." indicator
- Threaded replies with visual indentation
- Conversation List:
- Last message preview
- Unread badge count
- Auto-sort (new messages move conversation to top)
- Connection Status:
- Green indicator when connected
- "Reconnecting..." yellow indicator
- "Connection lost" red indicator with retry
Data Models
1. Message
public record Message
{
public Guid MessageId { get; init; }
public Guid ConversationId { get; init; }
public Guid SenderId { get; init; } // UserId
public string SenderName { get; init; }
public Guid SenderEntityId { get; init; } // DealerId or CustomerId
public UserType SenderType { get; init; } // Dealer/Customer
public string MessageText { get; init; }
public ReplyToMessage? ReplyToMessage { get; init; }
public List<string>? Attachments { get; init; }
public bool IsDelivered { get; init; }
public bool IsSeen { get; init; }
public DateTime? SeenAt { get; init; }
public string EntityName { get; init; }
public string EntityImage { get; init; }
public DateTime CreatedAt { get; init; }
}
Frontend receives this in: ReceiveMessage SignalR event
Example JSON Response:
{
"messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderName": "John Doe",
"senderEntityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderType": 2,
"messageText": "We can offer a 5% discount on this vehicle",
"replyToMessage": null,
"attachments": [],
"isDelivered": true,
"isSeen": false,
"seenAt": null,
"entityName": "ABC Motors",
"entityImage": "https://cdn.example.com/dealer-logo.jpg",
"createdAt": "2025-12-03T10:30:00Z"
}
2. DealerConversation
public record DealerConversation
{
public Guid ConversationId { get; init; }
public Guid DealerId { get; init; }
public Guid AuctionId { get; init; }
public string AuctionCode { get; init; }
// ⚠️ PRIVACY: These are NULL unless IsWon = true
public Guid? CustomerId { get; init; }
public string? CustomerName { get; init; }
public string? CustomerEmail { get; init; }
public string? CustomerPhone { get; init; }
public bool IsWon { get; init; }
public string LastMessage { get; init; }
public int UnreadCount { get; init; }
public string? LastMessageSentBy { get; init; }
public DateTime UpdatedAt { get; init; }
}
Frontend receives this in:
- HTTP GET
/chat/dealer/conversations - SignalR
ConversationUpdatedevent (for dealers)
Example JSON Response (Non-Winning Dealer):
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 2,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:35:00Z"
}
Example JSON Response (Winning Dealer):
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerName": "Jane Smith",
"customerEmail": "jane.smith@example.com",
"customerPhone": "+1234567890",
"isWon": true,
"lastMessage": "When can we finalize the deal?",
"unreadCount": 0,
"lastMessageSentBy": "Jane Smith",
"updatedAt": "2025-12-03T11:00:00Z"
}
3. CustomerConversation
public record CustomerConversation
{
public Guid ConversationId { get; init; }
public Guid CustomerId { get; init; }
public Guid DealerId { get; init; }
public string DealerName { get; init; }
public string DealerEmail { get; init; }
public string DealerPhone { get; init; }
public string Image { get; init; }
public bool IsWon { get; init; }
public string LastMessage { get; init; }
public int UnreadCount { get; init; }
public Guid AuctionId { get; init; }
public string AuctionCode { get; init; }
public DateTime UpdatedAt { get; init; }
}
Frontend receives this in:
- HTTP GET
/chat/customer/auction-rooms/{auctionId}/conversations - SignalR
ConversationUpdatedevent (for customers)
Example JSON Response:
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerName": "ABC Motors",
"dealerEmail": "sales@abcmotors.com",
"dealerPhone": "+1987654321",
"image": "https://cdn.example.com/dealer-logo.jpg",
"isWon": true,
"lastMessage": "We can offer a 5% discount",
"unreadCount": 1,
"auctionId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"updatedAt": "2025-12-03T10:30:00Z"
}
4. SignalR Event Data
ReadReceiptEventData - When messages are marked as read
public record ReadReceiptEventData
{
public Guid ConversationId { get; init; }
public Guid ReadByUserId { get; init; }
public Guid ReadByEntityId { get; init; }
public UserType ReadByUserType { get; init; }
public DateTime ReadAt { get; init; }
}
Frontend receives this in: MessageRead SignalR event
Example JSON:
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByEntityId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserType": 4,
"readAt": "2025-12-03T10:40:00Z"
}
TypingIndicatorEventData - When user starts/stops typing
public record TypingIndicatorEventData
{
public Guid ConversationId { get; init; }
public Guid UserId { get; init; }
public string UserName { get; init; }
public Guid EntityId { get; init; }
public string? EntityName { get; init; }
public UserType UserType { get; init; }
public bool IsTyping { get; init; }
}
Frontend receives this in: TypingIndicator SignalR event
Example JSON (Typing):
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": true
}
Example JSON (Stopped Typing):
{
"conversationId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": false
}
5. Enums
public enum UserType
{
Admin = 1,
Dealer = 2,
Customer = 4
}
public enum ConversationStatus
{
Active = 1,
Disable = 2
}
public enum ConversationUpdateType
{
NewMessage = 1,
StatusChanged = 2,
MessageRead = 3
}
API Interfaces
HTTP Endpoints
| Endpoint | Method | Parameters | Request Body | Response | Status Codes |
|---|---|---|---|---|---|
/chat/message | POST | JWT in header | SendMessageRequest | { "messageId": "guid" } | 200, 400, 401, 403, 500 |
/chat/conversations/{id}/mark-read | PUT | id (Guid): Conversation IDJWT in header | None | { "status": 200, "message": "Marked as read" } | 200, 401, 403, 404, 500 |
/chat/dealer/conversations | GET | limit, nextCursor, previousCursor, auctionId, statusJWT in header | None | CursorPaginatedData<DealerConversation> | 200, 400, 401, 500 |
/chat/customer/auction-rooms | GET | limit, nextCursor, previousCursor, statusJWT in header | None | CursorPaginatedData<AuctionChatRoom> | 200, 401, 500 |
/chat/customer/auction-rooms/{id}/conversations | GET | id (Guid): Auction IDlimit, nextCursor, previousCursorJWT in header | None | CursorPaginatedData<CustomerConversation> | 200, 401, 500 |
/chat/conversations/{id}/messages | GET | id (Guid): Conversation IDlimit, nextCursor, previousCursor, searchKeywordJWT in header | None | CursorPaginatedData<Message> | 200, 401, 403, 404, 500 |
1. Send Message
Frontend sends:
POST /chat/message
Authorization: Bearer {JWT_TOKEN}
Content-Type: application/json
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message": "We can offer a 5% discount on this vehicle",
"replyToMessageId": null,
"attachments": []
}
Backend responds:
{
"status": 200,
"messageId": "msg-abc-def-123"
}
Then SignalR automatically broadcasts ReceiveMessage event to all participants with full message object (see Message example above).
2. Mark as Read
Frontend sends:
PUT /chat/conversations/conv-1234-5678/mark-read
Authorization: Bearer {JWT_TOKEN}
Backend responds:
{
"status": 200,
"message": "Messages marked as read successfully"
}
Then SignalR automatically broadcasts:
MessageReadevent withReadReceiptEventDataConversationUpdatedevent with updatedUnreadCount = 0
3. Get Dealer Conversations
Frontend sends:
GET /chat/dealer/conversations?limit=20&auctionId=auction-abc-def&status=1
Authorization: Bearer {JWT_TOKEN}
Backend responds:
{
"data": [
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 2,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:35:00Z"
}
],
"nextCursor": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"previousCursor": null,
"hasMore": true
}
4. Get Messages
Frontend sends:
GET /chat/conversations/conv-1234-5678/messages?limit=50
Authorization: Bearer {JWT_TOKEN}
Backend responds:
{
"data": [
{
"messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"conversationId": "bb2c3d4-e5f6-7890-abcd-ef1234567890",
"senderId": "u1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderName": "John Doe",
"senderEntityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderType": 2,
"messageText": "Hello, I'm interested in this vehicle",
"replyToMessage": null,
"attachments": [],
"isDelivered": true,
"isSeen": true,
"seenAt": "2025-12-03T10:40:00Z",
"entityName": "ABC Motors",
"entityImage": "https://cdn.example.com/logo.jpg",
"createdAt": "2025-12-03T10:30:00Z"
}
],
"nextCursor": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"previousCursor": null,
"hasMore": true
}
SignalR Hub
Hub Endpoint: /realtimehub
Authentication: JWT token via query string ?access_token={JWT_TOKEN}
Client → Server Hub Methods
These are methods frontend can invoke on the hub:
| Method | Parameters | Description | When to Call |
|---|---|---|---|
StartTyping | conversationId (Guid) | Notify others user is typing | When user starts typing in input field |
StopTyping | conversationId (Guid) | Notify others user stopped typing | After 2-3 seconds of no typing OR when message is sent |
GetInitialPresenceData | None | Fetch presence data for all conversations | After component mounts (if connection already established) |
Heartbeat | None | Refresh connection TTL in Valkey (prevents auto-disconnect) | Every 2 minutes via setInterval |
Frontend invokes:
// User starts typing
connection.invoke("StartTyping", "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
// User stops typing (debounced)
connection.invoke("StopTyping", "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
// Request presence data (when chat component mounts)
connection.invoke("GetInitialPresenceData");
// Keep connection alive (heartbeat)
setInterval(() => {
connection.invoke("Heartbeat");
}, 120000); // 2 minutes
Server → Client Events
These are events frontend listens for:
| Event Name | Payload Type | Description | When Received |
|---|---|---|---|
ReceiveMessage | Message | New message in conversation | After any participant sends a message via HTTP |
MessageRead | ReadReceiptEventData | Messages marked as read | After any participant marks messages as read via HTTP |
TypingIndicator | TypingIndicatorEventData | User typing status | When another user invokes StartTyping/StopTyping |
ConversationUpdated | DealerConversationUpdateEventData or CustomerConversationUpdateEventData | Conversation metadata changed | After new message or read receipt |
InitialPresenceData | EntityPresenceEventData[] | Presence status for all conversations | On connection OR after invoking GetInitialPresenceData hub method |
EntityPresenceChanged | EntityPresenceEventData | Entity went online/offline | When any conversation participant connects/disconnects from SignalR |
WebSocket Response Examples by Scenario
Scenario 1: User Sends a Message
When a user sends a message via HTTP POST, they receive 2 WebSocket events:
Event 1: ReceiveMessage - Broadcasts to all conversation participants
{
"messageId": "f1b2c3d4-e5f6-7890-abcd-ef1234567890",
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderName": "John Doe",
"senderType": 4,
"text": "I would like to make an offer",
"attachments": null,
"sentAt": "2025-12-03T10:30:00Z",
"isRead": false,
"readAt": null
}
Event 2: ConversationUpdated - Broadcasts to entities (dealer or customer based on recipient)
For Dealers:
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 1,
"updatedAt": "2025-12-03T10:35:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "I would like to make an offer",
"unreadCount": 1,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:35:00Z"
}
}
For Customers:
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 1,
"updatedAt": "2025-12-03T10:35:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerName": "ABC Motors",
"dealerEmail": "sales@abcmotors.com",
"dealerPhone": "+1987654321",
"image": "https://cdn.example.com/dealer-logo.jpg",
"isWon": false,
"lastMessage": "I would like to make an offer",
"unreadCount": 1,
"lastMessageSentBy": "ABC Motors",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"updatedAt": "2025-12-03T10:35:00Z"
}
}
Scenario 2: User Marks Messages as Read
When a user marks messages as read via HTTP PUT, they receive 2 WebSocket events:
Event 1: MessageRead - Broadcasts to all conversation participants
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByEntityId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserType": 4,
"readAt": "2025-12-03T10:40:00Z"
}
Event 2: ConversationUpdated - Broadcasts to entities with updated unread count
For Dealers:
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 3,
"updatedAt": "2025-12-03T10:40:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 0,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:40:00Z"
}
}
For Customers:
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 3,
"updatedAt": "2025-12-03T10:40:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerName": "ABC Motors",
"dealerEmail": "sales@abcmotors.com",
"dealerPhone": "+1987654321",
"image": "https://cdn.example.com/dealer-logo.jpg",
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 0,
"lastMessageSentBy": "ABC Motors",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"updatedAt": "2025-12-03T10:40:00Z"
}
}
Scenario 3: User Starts Typing
When a user calls StartTyping(conversationId) via SignalR hub, other participants receive 1 WebSocket event:
Event: TypingIndicator - Broadcasts to all conversation participants except the sender
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": true
}
Scenario 4: User Stops Typing
When a user calls StopTyping(conversationId) via SignalR hub, other participants receive 1 WebSocket event:
Event: TypingIndicator - Broadcasts to all conversation participants except the sender
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": false
}
Scenario 5: User Comes Online (Connects to SignalR)
When a user connects to the SignalR hub, the system tracks their presence and broadcasts to other conversation participants:
Event 1: InitialPresenceData - Sent ONLY to the newly connected user (all conversations at once)
[
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:20:00Z"
},
{
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "e1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T09:45:00Z"
}
]
Event 2: EntityPresenceChanged - Broadcasts to OTHER participants in all conversations where this entity participates
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:30:00Z"
}
Scenario 6: User Goes Offline (Disconnects from SignalR)
When a user disconnects (closes browser, network loss, logout), the system detects it and broadcasts:
Event: EntityPresenceChanged - Broadcasts to OTHER participants in all conversations where this entity participates
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T11:45:00Z"
}
Scenario 7: Frontend Requests Presence Data (Component Mounted Late)
When frontend component mounts after the SignalR connection is already established (e.g., navigating to chat page), it can request presence data:
Frontend invokes:
await connection.invoke("GetInitialPresenceData");
Backend responds with Event: InitialPresenceData - Sent ONLY to the caller
[
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:20:00Z"
},
{
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "e1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T09:45:00Z"
}
]
Entity Presence Tracking (Online/Offline Indicators)
Overview:
The system tracks real-time presence status of all entities (dealers and customers) using Valkey (Redis-compatible) for distributed state management. When users connect/disconnect from SignalR, their presence is tracked and broadcast to all conversation participants.
Key Features:
- Entity-Level Tracking: Presence is tracked per entity (dealer/customer), not per user. If multiple dealer users from the same dealership are online, the dealer entity shows as "online"
- Multi-Device Support: Entity remains online as long as ANY user/device is connected. Goes offline only when ALL connections are closed
- Connection-Level Granularity: Each WebSocket connection is tracked individually with metadata (entityId, userId, connectionId)
- Automatic Cleanup: Valkey TTL (Time-To-Live) ensures stale connections are cleaned up if heartbeat fails
- Timestamp Tracking: Stores
LastStatusChangeTimestampin Valkey to show "offline 29 minutes ago" or "online since 10:30 AM"
Valkey (Redis) Data Structures
1. Entity Connections Set - Tracks all active connections for an entity
Key: entity:{entityId}:connections
Type: Set<string>
Value: ["connectionId1", "connectionId2", ...]
TTL: 5 minutes (refreshed by heartbeat)
Example:
entity:d1b2c3d4-e5f6-7890-abcd-ef1234567890:connections
= {"conn-abc-123", "conn-def-456"}
2. Connection Metadata Hash - Stores metadata for reverse lookup
Key: connection:{connectionId}:metadata
Type: Hash
Fields: entityId, userId, connectedAt
TTL: 5 minutes (refreshed by heartbeat)
Example:
connection:conn-abc-123:metadata
= {
entityId: "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
userId: "u1b2c3d4-e5f6-7890-abcd-ef1234567890",
connectedAt: "2025-12-03T10:30:00Z"
}
3. Entity Last Status Change Timestamp - Tracks when entity went online/offline
Key: entity:{entityId}:last_status_change
Type: String (ISO 8601 DateTime)
TTL: 24 hours (persists longer than connections)
Example:
entity:d1b2c3d4-e5f6-7890-abcd-ef1234567890:last_status_change
= "2025-12-03T10:30:00Z"
Presence Tracking Workflow
Connection Flow (User Comes Online):
User Device RealTimeHub PresenceTracker Valkey Other Participants
| | | | |
| SignalR Connect | | | |
|-------------------------->| | | |
| | | | |
| | AddConnectionAsync() | | |
| |-------------------------->| | |
| | | | |
| | | SADD entity:connections | |
| | |-------------------------->| |
| | | | |
| | | Check: First connection? | |
| | | (SET size was 0, now 1) | |
| | | | |
| | | SET last_status_change | |
| | |-------------------------->| |
| | | | |
| | wentOnline = true | | |
| |<--------------------------| | |
| | | | |
| | GetConversationPresence() | | |
| |-------------------------->| | |
| | | | |
| | | Batch query all entities | |
| | |-------------------------->| |
| | | | |
| | InitialPresenceData | | |
| <InitialPresenceData> | | | |
|<--------------------------| | | |
| | | | |
| | BroadcastEntityPresence() | | |
| | (to all conversations) | | |
| |--------------------------------------------------------->| EntityPresenceChanged |
| | | | | {isOnline: true}
| | | | |
Disconnection Flow (User Goes Offline):
User Device RealTimeHub PresenceTracker Valkey Other Participants
| | | | |
| Disconnect | | | |
| (close tab/logout) | | | |
|-------------------------->| | | |
| | | | |
| | RemoveConnectionAsync() | | |
| |-------------------------->| | |
| | | | |
| | | SREM entity:connections | |
| | |-------------------------->| |
| | | | |
| | | Check: Last connection? | |
| | | (SET size now 0) | |
| | | | |
| | | SET last_status_change | |
| | |-------------------------->| |
| | | | |
| | wentOffline = true | | |
| |<--------------------------| | |
| | | | |
| | BroadcastEntityPresence() | | |
| | (to all conversations) | | |
| |--------------------------------------------------------->| EntityPresenceChanged |
| | | | | {isOnline: false}
| | | | |
Heartbeat Flow (Keep Connection Alive):
Frontend (Timer) RealTimeHub PresenceTracker Valkey
| | | |
| Every 2 minutes | | |
| invoke("Heartbeat") | | |
|-------------------------->| | |
| | | |
| | RefreshConnectionAsync() | |
| |-------------------------->| |
| | | |
| | | EXPIRE connections SET |
| | | (reset TTL to 5 min) |
| | |-------------------------->|
| | | |
| | | EXPIRE metadata HASH |
| | | (reset TTL to 5 min) |
| | |-------------------------->|
| | | |
Data Model for Presence
EntityPresenceEventData - Sent via SignalR events
public record EntityPresenceEventData
{
public Guid ConversationId { get; init; } // Which conversation this applies to
public Guid EntityId { get; init; } // Dealer/Customer/Admin ID
public UserType EntityType { get; init; } // Dealer = 2, Customer = 4
public bool IsOnline { get; init; } // true = online, false = offline
public DateTime? LastStatusChangeTimestamp { get; init; } // When they went online/offline
}
Frontend receives this in:
InitialPresenceDataevent - Array of all conversation participants (sent on connect or on-demand)EntityPresenceChangedevent - Single entity that just went online/offline
Example JSON Responses:
InitialPresenceData (Array) - Received on connection or via GetInitialPresenceData() hub method:
[
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:20:00Z"
},
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "e1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T09:45:00Z"
},
{
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "f1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:25:00Z"
}
]
EntityPresenceChanged (Single Object) - Received when someone goes online/offline:
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:30:00Z"
}
Hub Methods for Presence
| Method | Parameters | Description | When to Call |
|---|---|---|---|
GetInitialPresenceData | None | Fetch presence data for all conversations | After component mounts (if connection already established) |
Heartbeat | None | Refresh connection TTL in Valkey (prevents auto-disconnect) | Every 2 minutes via setInterval |
Privacy & Security Considerations
- Entity-Level Broadcasting: Presence changes are broadcast ONLY to conversation participants, not globally
- Sender Exclusion: Entity does not receive its own presence updates (GroupExcept pattern)
- TTL-Based Cleanup: Stale connections automatically cleaned up after 5 minutes of no heartbeat
- Fallback Mechanism: If context is unavailable during disconnect, Redis metadata lookup ensures cleanup
Performance Optimizations
- Batch Queries: Initial presence data fetches all conversation participants in ONE Valkey query, not N queries
- Connection Pooling: Valkey connection pool reuses connections across requests
- Selective Broadcasting: Only broadcasts to specific conversation groups, not all connected users
- TTL Refresh: Heartbeat method is lightweight (single Redis EXPIRE command per connection)
Third-Party Integrations
- MassTransit: Inter-service communication for fetching dealer/customer names
- PostgreSQL: Database for messages, conversations
- MessagePack: Binary protocol for SignalR (30-50% smaller payloads than JSON)
- Valkey (Redis-compatible): Distributed presence tracking for online/offline status, connection management with TTL-based cleanup
Workflow
1. Connection Flow
Frontend Backend (RealTimeHub)
| |
| Connect to /realtimehub |
| ?access_token={JWT} |
|------------------------------------->|
| |
| | Validate JWT
| | Extract: userId, userType, entityId
| |
| | Add to groups:
| | - User-{userId}
| | - Entity-{entityId}
| | - Conversation-{id1}
| | - Conversation-{id2}...
| |
| ✅ Connected |
|<-------------------------------------|
| |
2. Send Message Flow (Hybrid: HTTP + SignalR)
Sender ChatController SignalR Recipients
Frontend (HTTP) Hub Frontend
| | | |
| POST /chat/message | | |
|----------------------->| | |
| | | |
| | Validate & Save | |
| | to Database | |
| | | |
| HTTP 200 | | |
| {messageId} | | |
|<-----------------------| | |
| | | |
| (Show message in UI) | Broadcast | |
| | ReceiveMessage | |
| |----------------------->| |
| | | |
| | | ReceiveMessage event |
| | |----------------------->|
| | | |
| | | | (Show message)
| | | |
| | Broadcast | |
| | ConversationUpdated | |
| |----------------------->| |
| | | |
| | | ConversationUpdated |
| | |----------------------->|
| | | |
| | | | (Update list)
Key Points:
- HTTP ensures message is saved to database (reliable)
- SignalR provides instant delivery to all participants (real-time)
- Sender gets HTTP confirmation immediately
- Recipients get SignalR broadcast within milliseconds
3. Typing Indicator Flow
User 1 SignalR Hub User 2
Frontend Frontend
| | |
| User starts typing | |
| invoke("StartTyping") | |
|----------------------->| |
| | |
| | TypingIndicator |
| | {isTyping: true} |
| |----------------------->|
| | |
| | | Show "User 1 is typing..."
| | |
| User stops typing | |
| invoke("StopTyping") | |
|----------------------->| |
| | |
| | TypingIndicator |
| | {isTyping: false} |
| |----------------------->|
| | |
| | | Hide typing indicator
4. Mark as Read Flow
Customer ChatController SignalR Dealer
Frontend (HTTP) Hub Frontend
| | | |
| PUT .../mark-read | | |
|----------------------->| | |
| | | |
| | Update database | |
| | (Mark dealer msgs | |
| | as read) | |
| | | |
| HTTP 200 | | |
|<-----------------------| | |
| | | |
| (Clear unread badge) | Broadcast | |
| | MessageRead | |
| |----------------------->| |
| | | |
| | | MessageRead event |
|<-----------------------|------------------------|----------------------->|
| | | |
| | | | (Show blue ticks)
| | | |
| | Broadcast | |
| | ConversationUpdated | |
| | (UnreadCount = 0) | |
| |----------------------->| |
| | | |
|<-----------------------|------------------------|----------------------->|
| | | |
| (Update conversation) | | | (Update conversation)
Development Tasks & Estimates
| No | Task Name | Estimate (Hours) | Dependencies |
|---|---|---|---|
| 1 | Configure SignalR in Program.cs with MessagePack | 2 | - |
| 2 | Implement RealTimeHub with connection lifecycle | 4 | Task 1 |
| 3 | Auto-join users to conversation groups on connection | 4 | Task 2 |
| 4 | Implement SignalRBroadcastService | 5 | Task 2 |
| 5 | Broadcast ReceiveMessage after HTTP POST /chat/message | 3 | Task 4 |
| 6 | Broadcast MessageRead after HTTP PUT /mark-read | 3 | Task 4 |
| 7 | Implement StartTyping/StopTyping hub methods | 4 | Task 2 |
| 8 | Broadcast ConversationUpdated for last message preview | 3 | Task 5 |
| 9 | Privacy filtering for dealer conversations (IsWon check) | 3 | Task 8 |
| 10 | JWT authentication via query string for SignalR | 2 | Task 1 |
| 11 | Sender exclusion logic (GroupExcept for broadcasts) | 3 | Task 8 |
| 12 | Implement Valkey-based presence tracking (IEntityPresenceTracker) | 6 | Task 1 |
| 13 | Implement presence broadcasting (online/offline) | 5 | Task 12 |
| 14 | Implement GetInitialPresenceData hub method | 3 | Task 12, 13 |
| 15 | Implement Heartbeat method for connection TTL refresh | 2 | Task 12 |
| 16 | Integration testing and bug fixes | 10 | All tasks |
| Total | 62 hours |
Testing & Quality Assurance
Unit Tests
- JWT token validation in OnConnectedAsync
- Group membership logic
- Privacy filtering (IsWon check)
- SignalRBroadcastService methods
Integration Tests
- End-to-end message sending (HTTP → Database → SignalR → Recipients)
- Read receipts propagation
- Typing indicators across multiple users
- Multi-device support (same user on 2 browsers)
- Reconnection after network interruption
Acceptance Criteria
- ✅ Messages delivered in < 500ms
- ✅ Read receipts update in real-time across all devices
- ✅ Typing indicators appear/disappear correctly
- ✅ Privacy rules enforced (customer contact info hidden unless dealer won)
- ✅ No duplicate messages
- ✅ Automatic reconnection works
- ✅ Conversation list updates in real-time
- ✅ Online/offline indicators update instantly when users connect/disconnect
- ✅ Presence data available on component mount (GetInitialPresenceData)
- ✅ Multi-device presence works (entity stays online if ANY device connected)
- ✅ Heartbeat keeps connection alive (TTL refresh every 2 minutes)
- ✅ Stale connections cleaned up automatically after 5 minutes
- ✅ LastStatusChangeTimestamp shows accurate "offline 29 minutes ago" text
Testing Tools
- xUnit for backend unit tests
- SignalR test framework for hub testing
- Postman for HTTP endpoint testing
- Browser DevTools for SignalR connection debugging
Deployment Considerations
Configuration Changes
Program.cs:
- Add SignalR services with MessagePack protocol
- Configure JWT authentication to accept tokens from query string
- Configure CORS to allow credentials (required for SignalR)
- Map SignalR hub endpoint
/realtimehub - Configure Valkey (Redis) connection for presence tracking
- Register
IEntityPresenceTrackerservice with dependency injection
NuGet Packages:
Microsoft.AspNetCore.SignalRMicrosoft.AspNetCore.SignalR.Protocols.MessagePackStackExchange.Redis(for Valkey connection)
Rollout Plan
- Deploy to staging environment
- Test with QA team (5-10 concurrent users)
- Monitor SignalR connection logs
- Gradual rollout to production (10% → 50% → 100%)
- Monitor performance metrics (connection count, message latency)
Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation Strategy |
|---|---|---|---|
| WebSocket blocked by corporate firewall | Medium | Low | SignalR automatic fallback to SSE/Long Polling |
| High concurrent connections overload | High | Medium | Implement connection limits, load balancing |
| Message delivery failure | High | Low | HTTP ensures database persistence, retry logic |
| JWT token expiry during long sessions | Medium | Medium | Token refresh mechanism, graceful reconnection |
| Privacy leak (customer info exposed) | High | Low | IsWon check enforced before all dealer broadcasts |
| Duplicate messages | Low | Low | Sender exclusion via GroupExcept, frontend deduplication |
Review & Approval
-
Technical Reviewer:
-
Business Reviewer:
-
Approval Date:
Notes
-
Why Hybrid Architecture?: HTTP provides reliability and database persistence, while SignalR provides instant real-time delivery. Best of both worlds.
-
Privacy is Critical: Customer contact information (name, email, phone) is only visible to dealers who have won the auction (
IsWon = true). This is enforced at multiple layers (DAL, Service, SignalR broadcasts). -
Multi-User Support: Multiple dealer users from the same dealership can all see the same conversations. SignalR groups ensure all users receive updates.
-
Presence Tracking: Uses Valkey (Redis-compatible) for distributed state management. Entity-level tracking means a dealer entity shows "online" if ANY dealer user is connected. Automatic TTL-based cleanup prevents stale data.
-
Timing Issue Solution: The
GetInitialPresenceData()hub method solves the race condition where presence data is sent automatically during connection but arrives before the frontend component is ready to store it. Frontend can now request presence data whenever needed. -
Extensibility: This architecture is designed to support future features like real-time auction updates and live bidding system.
-
Frontend Integration: Frontend developers should connect to
/realtimehubwith JWT token, listen for the 6 main events (ReceiveMessage, MessageRead, TypingIndicator, ConversationUpdated, InitialPresenceData, EntityPresenceChanged), invoke StartTyping/StopTyping for typing indicators, invoke GetInitialPresenceData when component mounts, and set up a 2-minute heartbeat timer.