SignalR trên .NET 10 — Real-Time Communication, Scale-Out và Notification Push cho Production
Posted on: 4/17/2026 9:10:35 PM
Table of contents
- 1. SignalR — Real-Time Framework cho .NET
- 2. Transport Protocols — WebSocket, SSE và Long Polling
- 3. Patterns thực chiến với SignalR
- 4. SignalR Client — JavaScript và Vue.js
- 5. Scale-Out — Từ single server tới triệu connections
- 6. SignalR vs SSE vs Raw WebSocket — Chọn đúng công cụ
- 7. Authentication và Authorization cho SignalR
- 8. Best Practices và Anti-Patterns
- 9. Kết luận
Trong thế giới web hiện đại, người dùng kỳ vọng mọi thứ phải tức thời — tin nhắn chat xuất hiện ngay lập tức, dashboard cập nhật real-time, notification push không cần refresh trang. SignalR trên ASP.NET Core là giải pháp production-ready để xây dựng các tính năng real-time này trên nền tảng .NET, với khả năng tự động chọn transport tối ưu, strongly-typed hub, và scale-out tới hàng triệu kết nối. Bài viết này sẽ đi sâu vào kiến trúc SignalR trên .NET 10, các pattern thực chiến, chiến lược scale-out với Redis Backplane và Azure SignalR Service, cùng so sánh với SSE và raw WebSocket.
1. SignalR — Real-Time Framework cho .NET
ASP.NET Core SignalR là một thư viện open-source giúp đơn giản hóa việc thêm tính năng real-time vào ứng dụng web. Thay vì phải tự quản lý WebSocket connections, heartbeat, reconnection và fallback, SignalR trừu tượng hóa toàn bộ complexity này thông qua khái niệm Hub — một high-level API cho phép server gọi method trên client và ngược lại.
1.1. Kiến trúc Hub — Trung tâm giao tiếp
Hub là khái niệm cốt lõi của SignalR. Nó hoạt động như một pipeline hai chiều giữa server và client. Server có thể gọi method trên bất kỳ client nào (hoặc nhóm client), và client có thể invoke method trên server — tất cả qua một connection duy nhất.
graph TB
C1["Client 1
(Browser)"] -->|"WebSocket"| HUB["SignalR Hub
(ASP.NET Core)"]
C2["Client 2
(Mobile)"] -->|"SSE Fallback"| HUB
C3["Client 3
(Desktop)"] -->|"WebSocket"| HUB
HUB -->|"SendAsync"| C1
HUB -->|"SendAsync"| C2
HUB -->|"SendAsync"| C3
HUB --> GROUPS["Groups
(room-123, admin)"]
HUB --> USERS["Users
(userId mapping)"]
GROUPS -->|"Group Broadcast"| C1
GROUPS -->|"Group Broadcast"| C3
style HUB fill:#e94560,stroke:#fff,color:#fff
style C1 fill:#2c3e50,stroke:#e94560,color:#fff
style C2 fill:#2c3e50,stroke:#e94560,color:#fff
style C3 fill:#2c3e50,stroke:#e94560,color:#fff
style GROUPS fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style USERS fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Hình 1: Kiến trúc SignalR Hub — server và client giao tiếp hai chiều qua một connection duy nhất
// Định nghĩa Hub trên server — .NET 10
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
// Gửi tới TẤT CẢ clients đang kết nối
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName)
.SendAsync("UserJoined", Context.User?.Identity?.Name);
}
public override async Task OnConnectedAsync()
{
// Tự động trigger khi client kết nối
await Clients.Caller.SendAsync("Connected", Context.ConnectionId);
await base.OnConnectedAsync();
}
}
1.2. Strongly-Typed Hub — Type Safety hai chiều
Một trong những điểm mạnh nhất của SignalR trên .NET là Strongly-Typed Hub. Thay vì dùng magic string "ReceiveMessage", bạn định nghĩa interface cho client methods — compiler sẽ kiểm tra tại build time, không còn runtime error do typo.
// Định nghĩa contract cho client methods
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserJoined(string userName);
Task UserLeft(string userName);
Task TypingIndicator(string userName, bool isTyping);
}
// Strongly-Typed Hub — compiler kiểm tra mọi SendAsync call
public class ChatHub : Hub<IChatClient>
{
public async Task SendMessage(string user, string message)
{
// Clients.All trả về IChatClient — có IntelliSense đầy đủ
await Clients.All.ReceiveMessage(user, message);
}
public async Task StartTyping()
{
var userName = Context.User?.Identity?.Name ?? "Anonymous";
await Clients.Others.TypingIndicator(userName, true);
}
}
Tại sao nên dùng Strongly-Typed Hub?
Với Strongly-Typed Hub, nếu bạn đổi tên method ReceiveMessage thành OnMessageReceived trên interface mà quên sửa phía client JavaScript, compiler sẽ báo lỗi ngay. Điều này đặc biệt quan trọng trong dự án lớn với nhiều Hub methods — tránh hoàn toàn lỗi runtime do typo mà chỉ phát hiện khi test thủ công.
2. Transport Protocols — WebSocket, SSE và Long Polling
SignalR tự động đàm phán transport tốt nhất tại thời điểm kết nối. Quá trình này diễn ra trong hai bước: (1) client gửi POST /negotiate để lấy danh sách transport server hỗ trợ, (2) client chọn transport tốt nhất mà cả hai bên đều support.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: POST /chat/negotiate
S-->>C: {connectionId, transports: [WS, SSE, LP]}
alt WebSocket available
C->>S: Upgrade: websocket
S-->>C: 101 Switching Protocols
Note over C,S: Full-duplex communication
else SSE fallback
C->>S: GET /chat?transport=SSE
S-->>C: text/event-stream (server→client)
C->>S: POST /chat (client→server)
else Long Polling last resort
C->>S: GET /chat?transport=LongPolling
Note over C,S: Server giữ request cho đến khi có data
S-->>C: Response with data
C->>S: GET (poll tiếp)
end
Hình 2: Quá trình transport negotiation — SignalR tự động chọn protocol tối ưu
| Transport | Hướng | Latency | Browser Support | Khi nào dùng |
|---|---|---|---|---|
| WebSocket | Full-duplex | Thấp nhất (~1ms) | Mọi browser hiện đại | Mặc định — chat, game, collaboration |
| Server-Sent Events | Server → Client | Thấp | Tất cả (trừ IE) | Dashboard, notification, progress bar |
| Long Polling | Giả lập duplex | Cao (poll interval) | Mọi browser | Fallback cuối cùng (corporate proxy) |
2.1. Server-Sent Events trên .NET 10 — Cải tiến đáng kể
.NET 10 bổ sung Results.ServerSentEvents — API mới cho phép wrap IAsyncEnumerable<T> thành SSE response chuẩn, không cần thư viện bên ngoài. Kết hợp với Kestrel hỗ trợ HTTP/2, SSE trên .NET 10 giải quyết được vấn đề giới hạn 6 kết nối trên mỗi domain (HTTP/1.1) — nhiều SSE stream có thể chia sẻ một TCP connection qua HTTP/2 multiplexing.
// .NET 10 — SSE với Minimal API (không cần SignalR)
app.MapGet("/dashboard/stream", (
DashboardService dashboard,
CancellationToken ct) =>
{
async IAsyncEnumerable<DashboardUpdate> StreamUpdates(
[EnumeratorCancellation] CancellationToken token)
{
while (!token.IsCancellationRequested)
{
yield return await dashboard.GetLatestMetricsAsync(token);
await Task.Delay(TimeSpan.FromSeconds(2), token);
}
}
return Results.ServerSentEvents(StreamUpdates(ct));
});
SignalR hay SSE thuần?
Nếu tính năng real-time của bạn chỉ cần server push một chiều (dashboard metrics, notification feed, progress bar), SSE với Results.ServerSentEvents trên .NET 10 là lựa chọn đơn giản hơn — không cần Hub, không cần client library, không cần sticky sessions hay backplane. SignalR mạnh hơn khi cần giao tiếp hai chiều (chat, collaborative editing, game state sync) hoặc cần quản lý Groups/Users phức tạp.
3. Patterns thực chiến với SignalR
3.1. Chat Room với Groups và Presence
Đây là use case kinh điển nhất của SignalR. Groups cho phép broadcast message tới một tập con clients, trong khi presence tracking theo dõi ai đang online.
public class ChatHub : Hub<IChatClient>
{
private static readonly ConcurrentDictionary<string, UserInfo> _onlineUsers = new();
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier!;
var userName = Context.User!.FindFirst("name")!.Value;
_onlineUsers.TryAdd(Context.ConnectionId, new UserInfo(userId, userName));
// Broadcast danh sách online users cho client mới
await Clients.Caller.OnlineUsers(_onlineUsers.Values.ToList());
await Clients.Others.UserJoined(userName);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (_onlineUsers.TryRemove(Context.ConnectionId, out var user))
await Clients.Others.UserLeft(user.Name);
}
public async Task SendToRoom(string room, string message)
{
var sender = _onlineUsers[Context.ConnectionId];
var chatMessage = new ChatMessage(sender.Name, message, DateTime.UtcNow);
await Clients.Group(room).ReceiveMessage(chatMessage);
}
public async Task JoinRoom(string room)
{
await Groups.AddToGroupAsync(Context.ConnectionId, room);
var sender = _onlineUsers[Context.ConnectionId];
await Clients.Group(room).UserJoined(sender.Name);
}
public async Task LeaveRoom(string room)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
var sender = _onlineUsers[Context.ConnectionId];
await Clients.Group(room).UserLeft(sender.Name);
}
}
3.2. Real-Time Dashboard với Background Service
Trong nhiều trường hợp, data real-time không đến từ client mà từ background process — worker xử lý đơn hàng, metrics collector, hoặc event consumer. SignalR cho phép inject IHubContext vào bất kỳ service nào để push data tới clients.
graph LR
DB["Database
Event Store"] -->|"Change Event"| BG["Background
Service"]
KAFKA["Kafka / Redis
Stream"] -->|"Consume"| BG
BG -->|"IHubContext"| HUB["SignalR Hub"]
HUB -->|"Push Update"| D1["Dashboard 1"]
HUB -->|"Push Update"| D2["Dashboard 2"]
HUB -->|"Push Update"| D3["Mobile App"]
style HUB fill:#e94560,stroke:#fff,color:#fff
style BG fill:#2c3e50,stroke:#e94560,color:#fff
style DB fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style KAFKA fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style D1 fill:#2c3e50,stroke:#e94560,color:#fff
style D2 fill:#2c3e50,stroke:#e94560,color:#fff
style D3 fill:#2c3e50,stroke:#e94560,color:#fff
Hình 3: Push data từ background service tới dashboard clients qua IHubContext
// Background Service push metrics tới dashboard mỗi 2 giây
public class MetricsPushService : BackgroundService
{
private readonly IHubContext<DashboardHub, IDashboardClient> _hubContext;
private readonly IMetricsCollector _metrics;
public MetricsPushService(
IHubContext<DashboardHub, IDashboardClient> hubContext,
IMetricsCollector metrics)
{
_hubContext = hubContext;
_metrics = metrics;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var snapshot = await _metrics.CollectAsync(ct);
// Push tới tất cả clients đang xem dashboard
await _hubContext.Clients
.Group("dashboard-viewers")
.MetricsUpdated(new DashboardSnapshot
{
ActiveUsers = snapshot.ActiveUsers,
RequestsPerSecond = snapshot.RPS,
ErrorRate = snapshot.ErrorRate,
P99Latency = snapshot.P99,
Timestamp = DateTime.UtcNow
});
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}
}
3.3. Targeted Notification — Gửi đúng người, đúng lúc
SignalR hỗ trợ gửi message tới một user cụ thể thông qua Clients.User(userId). User identifier được map tự động từ ClaimTypes.NameIdentifier trong JWT token hoặc cookie authentication.
// Gửi notification tới một user cụ thể từ bất kỳ đâu trong app
public class OrderService
{
private readonly IHubContext<NotificationHub, INotificationClient> _hub;
public async Task CompleteOrderAsync(Order order)
{
await _repository.UpdateStatusAsync(order.Id, OrderStatus.Completed);
// Push notification real-time tới user (dù họ mở nhiều tab/device)
await _hub.Clients.User(order.UserId).OrderStatusChanged(new OrderNotification
{
OrderId = order.Id,
Status = "Completed",
Message = $"Đơn hàng #{order.Id} đã hoàn thành!",
Timestamp = DateTime.UtcNow
});
}
}
User vs ConnectionId
Clients.User(userId) gửi tới tất cả connections của một user (nhiều tab, nhiều device). Clients.Client(connectionId) chỉ gửi tới một connection cụ thể. Trong hầu hết use case notification, hãy dùng User() — user mở app trên cả laptop và điện thoại đều nhận được thông báo.
4. SignalR Client — JavaScript và Vue.js
Phía client, SignalR cung cấp thư viện @microsoft/signalr cho JavaScript/TypeScript. Tích hợp với Vue.js thông qua composable pattern.
// composables/useSignalR.ts — Vue 3 composable cho SignalR
import { ref, onMounted, onUnmounted } from 'vue'
import * as signalR from '@microsoft/signalr'
export function useSignalR(hubUrl: string) {
const connection = ref<signalR.HubConnection | null>(null)
const isConnected = ref(false)
const start = async () => {
connection.value = new signalR.HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: () => localStorage.getItem('token') || ''
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalR.LogLevel.Warning)
.build()
connection.value.onreconnecting(() => {
isConnected.value = false
})
connection.value.onreconnected(() => {
isConnected.value = true
})
connection.value.onclose(() => {
isConnected.value = false
})
await connection.value.start()
isConnected.value = true
}
const on = (method: string, callback: (...args: any[]) => void) => {
connection.value?.on(method, callback)
}
const invoke = async (method: string, ...args: any[]) => {
return connection.value?.invoke(method, ...args)
}
onMounted(start)
onUnmounted(() => connection.value?.stop())
return { connection, isConnected, on, invoke }
}
<!-- components/ChatRoom.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSignalR } from '@/composables/useSignalR'
const { on, invoke, isConnected } = useSignalR('/hubs/chat')
const messages = ref<ChatMessage[]>([])
const newMessage = ref('')
const currentRoom = ref('general')
onMounted(() => {
on('ReceiveMessage', (msg: ChatMessage) => {
messages.value.push(msg)
})
on('UserJoined', (userName: string) => {
messages.value.push({
sender: 'System',
content: `${userName} đã tham gia phòng`,
timestamp: new Date()
})
})
invoke('JoinRoom', currentRoom.value)
})
const sendMessage = async () => {
if (!newMessage.value.trim()) return
await invoke('SendToRoom', currentRoom.value, newMessage.value)
newMessage.value = ''
}
</script>
5. Scale-Out — Từ single server tới triệu connections
Mặc định, SignalR lưu connection state trong memory của một process. Khi deploy nhiều server instances (horizontal scaling), client A kết nối server 1 không thể nhận message từ client B kết nối server 2. Đây là bài toán scale-out cần giải quyết.
5.1. Redis Backplane — Scale-out tự quản lý
Redis Backplane sử dụng Redis Pub/Sub để đồng bộ messages giữa các server instances. Khi server 1 cần broadcast message, nó publish lên Redis channel; tất cả server khác subscribe channel đó và forward message tới clients của mình.
graph TB
C1["Client A"] --> S1["Server 1"]
C2["Client B"] --> S1
C3["Client C"] --> S2["Server 2"]
C4["Client D"] --> S2
C5["Client E"] --> S3["Server 3"]
S1 <-->|"Pub/Sub"| REDIS["Redis
Backplane"]
S2 <-->|"Pub/Sub"| REDIS
S3 <-->|"Pub/Sub"| REDIS
LB["Load Balancer
(Sticky Sessions)"] --> S1
LB --> S2
LB --> S3
style REDIS fill:#e94560,stroke:#fff,color:#fff
style LB fill:#2c3e50,stroke:#e94560,color:#fff
style S1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style S2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style S3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style C1 fill:#2c3e50,stroke:#e94560,color:#fff
style C2 fill:#2c3e50,stroke:#e94560,color:#fff
style C3 fill:#2c3e50,stroke:#e94560,color:#fff
style C4 fill:#2c3e50,stroke:#e94560,color:#fff
style C5 fill:#2c3e50,stroke:#e94560,color:#fff
Hình 4: Redis Backplane — đồng bộ messages giữa nhiều server instances qua Pub/Sub
// Program.cs — Cấu hình Redis Backplane
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR()
.AddStackExchangeRedis(options =>
{
options.Configuration = builder.Configuration
.GetConnectionString("Redis")!;
options.Configuration.ChannelPrefix =
RedisChannel.Literal("ChatApp");
});
var app = builder.Build();
app.MapHub<ChatHub>("/hubs/chat");
app.Run();
Redis Backplane vẫn cần Sticky Sessions!
SignalR sử dụng quy trình negotiate hai bước: bước 1 client gọi /negotiate để lấy connectionId, bước 2 client mở transport (WebSocket/SSE) với connectionId đó. Hai request này phải đến cùng một server — nếu không, server thứ hai sẽ không tìm thấy connection context. Cấu hình load balancer với sticky sessions (dựa trên cookie hoặc IP) là bắt buộc khi dùng Redis Backplane.
5.2. Azure SignalR Service — Managed Scale-out
Nếu bạn deploy trên Azure và muốn tránh quản lý Redis backplane + sticky sessions, Azure SignalR Service là giải pháp managed. Toàn bộ WebSocket connections được quản lý bởi Azure — app server chỉ xử lý business logic, không giữ connection state.
graph LR
C1["Clients"] -->|"WebSocket"| AZURE["Azure SignalR
Service"]
AZURE -->|"Server Connection"| S1["App Server 1"]
AZURE -->|"Server Connection"| S2["App Server 2"]
S1 --> DB["Database"]
S2 --> DB
style AZURE fill:#e94560,stroke:#fff,color:#fff
style S1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style S2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style C1 fill:#2c3e50,stroke:#e94560,color:#fff
style DB fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Hình 5: Azure SignalR Service — clients kết nối tới Azure, app server chỉ xử lý logic
// Program.cs — Azure SignalR Service
builder.Services.AddSignalR()
.AddAzureSignalR(options =>
{
options.ConnectionString = builder.Configuration
.GetConnectionString("AzureSignalR")!;
options.ServerStickyMode =
ServerStickyMode.Required; // Đảm bảo consistency
});
| Tiêu chí | Redis Backplane | Azure SignalR Service |
|---|---|---|
| Sticky Sessions | Bắt buộc | Không cần |
| Connection management | App server giữ | Azure quản lý |
| Scale limit | Phụ thuộc Redis + server | Hàng triệu connections |
| Latency | Thấp (cùng datacenter) | Thêm 1 hop tới Azure |
| Cost | Chi phí Redis server | Per unit pricing (~$50/unit) |
| Message loss khi Redis down | Có (không buffer) | Azure đảm bảo delivery |
| Chọn khi | Self-hosted, latency-critical | Azure-hosted, cần scale >10K connections |
6. SignalR vs SSE vs Raw WebSocket — Chọn đúng công cụ
Không phải mọi tính năng real-time đều cần SignalR. Dưới đây là framework quyết định dựa trên yêu cầu cụ thể:
| Yêu cầu | Giải pháp | Lý do |
|---|---|---|
| Server push một chiều (dashboard, notifications) | SSE + Results.ServerSentEvents | Không cần client library, không cần backplane, HTTP/2 multiplexing |
| Chat, collaborative editing, game | SignalR | Two-way communication, Groups, Users, auto-reconnect, type safety |
| Custom binary protocol, video/audio streaming | Raw WebSocket | Full control over framing, no overhead từ SignalR protocol |
| IoT sensor data (hàng nghìn devices) | MQTT hoặc Raw WebSocket | SignalR overhead không phù hợp cho IoT scale |
| Cần scale >100K connections trên Azure | Azure SignalR Service | Managed, không cần sticky sessions, auto-scale |
Nguyên tắc chung cho 2026
Phần lớn tính năng "real-time" trong ứng dụng enterprise thực chất chỉ cần unidirectional push — dashboard metrics, order status, notification feed. Trong những trường hợp này, SSE với Results.ServerSentEvents trên .NET 10 là lựa chọn đơn giản nhất với ít infrastructure overhead nhất. SignalR nên được dùng khi bạn thực sự cần bidirectional communication hoặc các tính năng cao cấp như Groups, Presence, và Strongly-Typed Hub.
7. Authentication và Authorization cho SignalR
SignalR Hub có thể sử dụng cùng authentication/authorization pipeline với ASP.NET Core. Với JWT Bearer token, client truyền token qua query string (vì WebSocket không hỗ trợ custom headers trong browser).
// Program.cs — Authentication cho SignalR Hub
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// SignalR truyền token qua query string
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken)
&& path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
// Hub với Authorization
[Authorize]
public class SecureChatHub : Hub<IChatClient>
{
[Authorize(Roles = "Admin")]
public async Task DeleteMessage(string messageId)
{
await Clients.All.MessageDeleted(messageId);
}
public async Task SendMessage(string message)
{
var userId = Context.UserIdentifier!;
var userName = Context.User!.FindFirst("name")!.Value;
await Clients.Others.ReceiveMessage(
new ChatMessage(userName, message, DateTime.UtcNow));
}
}
8. Best Practices và Anti-Patterns
8.1. Quản lý Connection Lifetime
// Client-side: Automatic Reconnect với exponential backoff
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/chat")
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
// Exponential backoff: 0s, 2s, 4s, 8s, 16s, max 30s
const delay = Math.min(
Math.pow(2, retryContext.previousRetryCount) * 1000,
30000
);
return delay;
}
})
.build();
// Xử lý reconnection events
connection.onreconnecting((error) => {
showToast("Đang kết nối lại...", "warning");
});
connection.onreconnected((connectionId) => {
showToast("Đã kết nối lại!", "success");
// Re-join rooms sau reconnect
connection.invoke("JoinRoom", currentRoom);
});
connection.onclose((error) => {
showToast("Mất kết nối. Vui lòng tải lại trang.", "error");
});
8.2. Anti-Patterns cần tránh
| Anti-Pattern | Vấn đề | Giải pháp |
|---|---|---|
| Gửi large payload qua Hub | Block connection, tăng memory pressure | Upload file qua HTTP, chỉ gửi notification qua SignalR |
| Blocking call trong Hub method | Block I/O thread, giảm throughput | Luôn dùng async/await, không Task.Result |
| Lưu state trong static field của Hub | Hub instance được tạo mới mỗi invocation | Dùng DI service (Singleton/Scoped) hoặc IHubContext |
| Không handle reconnection | User mất kết nối không biết | withAutomaticReconnect() + UI feedback |
| Broadcast quá thường xuyên | Flood client, tăng bandwidth | Throttle/debounce trên server, chỉ gửi delta |
Hub instance là Transient!
Mỗi khi client gọi một Hub method, ASP.NET Core tạo một Hub instance mới. Đừng lưu state vào property hoặc field của Hub class — nó sẽ bị mất sau mỗi invocation. Thay vào đó, dùng IHubContext inject vào Singleton service hoặc sử dụng external storage (Redis, database) cho shared state.
8.3. Performance Tuning
// Program.cs — Tối ưu SignalR cho production
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = false; // Production: tắt detailed errors
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.HandshakeTimeout = TimeSpan.FromSeconds(5);
options.MaximumReceiveMessageSize = 64 * 1024; // 64KB max message
options.StreamBufferCapacity = 10;
})
.AddMessagePackProtocol(); // Binary protocol thay vì JSON — nhỏ hơn ~30%
MessagePack Protocol
Mặc định SignalR dùng JSON protocol. Chuyển sang MessagePack (binary serialization) giảm ~30% kích thước message và tăng tốc serialization/deserialization. Đặc biệt hiệu quả khi gửi data có nhiều số (dashboard metrics, game state). Cài đặt: dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack.
9. Kết luận
SignalR trên .NET 10 tiếp tục là giải pháp real-time production-ready mạnh mẽ nhất trong hệ sinh thái .NET. Với Strongly-Typed Hub, automatic transport negotiation, và khả năng scale-out qua Redis Backplane hoặc Azure SignalR Service, nó giải quyết trọn vẹn bài toán giao tiếp real-time — từ chat đơn giản đến dashboard triệu connections.
Tuy nhiên, .NET 10 cũng mang đến Results.ServerSentEvents — một lựa chọn nhẹ hơn đáng kể cho các tính năng chỉ cần server push một chiều. Nguyên tắc chọn: SSE cho unidirectional push (dashboard, notification feed), SignalR cho bidirectional communication (chat, collaboration), và raw WebSocket cho custom binary protocol. Đừng mặc định dùng SignalR cho mọi tính năng real-time — chọn đúng công cụ giúp giảm complexity và infrastructure cost đáng kể.
Nguồn tham khảo
- Introduction to ASP.NET Core SignalR — Microsoft Learn
- Redis backplane for ASP.NET Core SignalR scale-out — Microsoft Learn
- ASP.NET Core SignalR production hosting and scaling — Microsoft Learn
- Performance best practices with gRPC — Microsoft Learn
- SSE vs SignalR vs WebSockets in ASP.NET Core (2026) — Coding Droplets
- Scaling SignalR With a Redis Backplane — Milan Jovanović
- Scaling SignalR: Scaleout strategies, limits & alternatives — Ably
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.