.NET Tutorial
Middle6 мин чтенияВопрос 15 из 19

Как работает SignalR: WebSockets, Long Polling, Server-Sent Events?

Зачем нужен SignalR

HTTP построен на модели «запрос → ответ»: сервер не может сам отправить данные клиенту. Для чатов, уведомлений, дашбордов, совместного редактирования, статусов заказов нужен push от сервера.

Polling

GET /updates каждые 5 с

в основном пустые ответы,
задержка до 5 с

Long Polling

GET висит до появления данных

ответ → сразу новый запрос

Server-Sent Events

один долгий HTTP-ответ,
сервер пишет события

только сервер → клиент

WebSockets

Upgrade HTTP → WebSocket

полный дуплекс,
минимальные накладные расходы

Polling

GET /updates каждые 5 с

в основном пустые ответы,
задержка до 5 с

Long Polling

GET висит до появления данных

ответ → сразу новый запрос

Server-Sent Events

один долгий HTTP-ответ,
сервер пишет события

только сервер → клиент

WebSockets

Upgrade HTTP → WebSocket

полный дуплекс,
минимальные накладные расходы

Способы получать обновления от сервера
Транспорт Направление Накладные расходы Поддержка
WebSockets Двустороннее Минимальные (фреймы по 2–14 байт заголовка) Все современные браузеры; прокси должен поддерживать Upgrade
Server-Sent Events Сервер → клиент Низкие Все браузеры (кроме старых IE)
Long Polling Имитация двустороннего Высокие (новый HTTP-запрос на каждую порцию) Везде

Как устанавливается соединение

СерверКлиентRPC-сообщения в обестороныкаждые 15 с — Ping(keep-alive)POST/hubs/orders/negotiate?negotiate-Version=1connectionToken, доступныетранспорты [WebSockets, SSE,LongPolling]GET /hubs/orders?id=token,Upgrade: websocket101 Switching Protocolshandshake: протокол json,версия 1handshake okInvocation:OrderStatusChanged(42,"Shipped")Invocation: JoinOrderGroup(42)
СерверКлиентRPC-сообщения в обестороныкаждые 15 с — Ping(keep-alive)POST/hubs/orders/negotiate?negotiate-Version=1connectionToken, доступныетранспорты [WebSockets, SSE,LongPolling]GET /hubs/orders?id=token,Upgrade: websocket101 Switching Protocolshandshake: протокол json,версия 1handshake okInvocation:OrderStatusChanged(42,"Shipped")Invocation: JoinOrderGroup(42)
Negotiate и установка транспорта

Если WebSocket не удался (прокси режет Upgrade, корпоративный firewall), клиент автоматически пробует SSE, затем Long Polling.

Сервер: хаб

C#
public interface IOrderClient                       // строго типизированный клиент
{
    Task OrderStatusChanged(int orderId, string status);
    Task Notification(string message);
}

[Authorize]
public sealed class OrdersHub(IOrderService orders) : Hub<IOrderClient>
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.UserIdentifier;        // из claim NameIdentifier
        await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
        await base.OnConnectedAsync();
    }

    public async Task SubscribeToOrder(int orderId)             // клиент вызывает этот метод
    {
        if (!await orders.BelongsToUserAsync(orderId, Context.UserIdentifier!))
            throw new HubException("Нет доступа к заказу");
        await Groups.AddToGroupAsync(Context.ConnectionId, $"order:{orderId}");
    }
}

builder.Services.AddSignalR().AddMessagePackProtocol();
app.MapHub<OrdersHub>("/hubs/orders");

Отправка из любого места приложения

C#
public sealed class ShippingService(IHubContext<OrdersHub, IOrderClient> hub)
{
    public async Task MarkShippedAsync(int orderId, CancellationToken ct)
    {
        // ... изменение в БД
        await hub.Clients.Group($"order:{orderId}").OrderStatusChanged(orderId, "Shipped");
    }
}

Клиент (JavaScript)

JavaScript
import * as signalR from "@microsoft/signalr";

const connection = new signalR.HubConnectionBuilder()
  .withUrl("/hubs/orders", { accessTokenFactory: () => getAccessToken() })
  .withAutomaticReconnect([0, 2000, 10000, 30000])   // задержки переподключения
  .build();

connection.on("OrderStatusChanged", (orderId, status) => updateUi(orderId, status));
connection.onreconnected(() => connection.invoke("SubscribeToOrder", currentOrderId)); // группы теряются!

await connection.start();
await connection.invoke("SubscribeToOrder", 42);

WebSocket API браузера не позволяет установить заголовок Authorization, поэтому токен передаётся в query-параметре access_token; на сервере его нужно забрать в JwtBearerEvents.OnMessageReceived для путей хаба.

Масштабирование

Клиент A

Сервер 1

Клиент B

Сервер 2

Клиент C

Сервер 3

Redis backplane
pub/sub

ShippingService на сервере 1:
Group order:42

Клиент A

Сервер 1

Клиент B

Сервер 2

Клиент C

Сервер 3

Redis backplane
pub/sub

ShippingService на сервере 1:
Group order:42

Backplane доставляет сообщения клиентам на других серверах
  • Соединение живёт на одном сервере. Без backplane Clients.Group("order:42") на сервере 1 не достанет клиента на сервере 2.
  • Redis backplane: AddSignalR().AddStackExchangeRedis(conn) — все серверы обмениваются сообщениями через pub/sub.
  • Azure SignalR Service: управляемый сервис держит все клиентские соединения сам, серверы приложения лишь отправляют ему сообщения — масштабирование до сотен тысяч соединений.
  • Sticky sessions: при negotiate + SSE/Long Polling последующие запросы должны попадать на тот же сервер. С WebSockets и skipNegotiation: true не нужны.

Практические советы

  • Не отправляйте большие объекты — передавайте событие + ID, клиент подгрузит данные через обычный API.
  • Группы не сохраняются при переподключении — клиент должен снова подписаться.
  • Доставка не гарантирована: во время разрыва сообщения теряются. Для критичных данных — хранить состояние на сервере и синхронизировать после переподключения.
  • Длинные соединения потребляют ресурсы: следите за числом соединений, MaximumReceiveMessageSize, таймаутами.
  • Стриминг: метод хаба может возвращать IAsyncEnumerable<T> или ChannelReader<T> — клиент получает поток значений.

Вопросы на засыпку

Чем SignalR отличается от «голых» WebSockets?

WebSocket — транспорт: поток фреймов без структуры. SignalR добавляет RPC-протокол, автоматический выбор транспорта и fallback, переподключение, группы и адресацию пользователей, аутентификацию, масштабирование через backplane. Цена — собственный протокол (нужен клиент SignalR).

Когда хватит Server-Sent Events без SignalR?

Когда нужен только поток событий от сервера к клиенту (уведомления, прогресс, ленты), а клиент отправляет редкие обычные HTTP-запросы. В .NET 10 есть TypedResults.ServerSentEvents(IAsyncEnumerable<T>). SSE работает поверх обычного HTTP, проще проходит через прокси.

Как отправить сообщение конкретному пользователю на всех его устройствах?

Clients.User(userId) — SignalR сопоставляет пользователя со всеми его соединениями через IUserIdProvider (по умолчанию claim NameIdentifier).

Что будет, если клиент не отвечает?

Сервер отправляет ping каждые KeepAliveInterval (15 с); если от клиента нет сообщений в течение ClientTimeoutInterval (30 с), соединение считается разорванным и вызывается OnDisconnectedAsync.

Итог

SignalR даёт push-коммуникацию в реальном времени с автоматическим выбором транспорта (WebSockets → SSE → Long Polling), RPC-моделью хабов, группами и адресацией пользователей. При горизонтальном масштабировании обязательно нужен backplane или Azure SignalR Service, а доставку критичных данных нужно подстраховывать синхронизацией состояния.