Skip to main content

Stateful Reconnect

Stateful reconnect is the ASP.NET Core ack protocol: when the transport drops, the logical connection — including in-flight invocations, streams and the outbound buffer — survives. Messages sent while the connection was down are replayed exactly once, and duplicates are filtered on the receiving side.

Why it matters​

A normal reconnect renegotiates and restarts the logical connection: pending invocations fail, and messages that were "in flight" when the socket dropped are lost. Stateful reconnect keeps the same connection token and logical state, so:

  • A long-running stream() survives a drop without missing or duplicating items.
  • Broadcasts sent while a client is disconnected are delivered when it returns.
  • Requests that were sent but not yet acked are resent.

Enabling it​

Client​

connection = HubConnection().with_stateful_reconnect()

Server​

server = SignalRServer(
ChatHub,
options=ServerOptions(
allow_stateful_reconnects=True,
stateful_reconnect_buffer_size=100_000, # bytes
stateful_reconnect_timeout=30.0, # seconds
),
)

Stateful reconnect requires the WebSockets transport. The client sends the negotiate flag useStatefulReconnect=true; if the server agrees it replies useStatefulReconnect: true and the handshake uses protocol version 2.

How it works​

Sequence ids​

Every trackable outbound message (invocation, stream item, completion, cancel) is assigned a monotonically increasing sequence id starting at 1. Pings, acks, sequences and close messages are not counted.

Buffering​

Unacknowledged messages are buffered on the sending side until the peer acknowledges them. The buffer is byte-bounded (stateful_reconnect_buffer_size, default 100,000); writers block on backpressure until an Ack frees space.

Ack​

After receiving trackable messages, each side sends an Ack carrying the latest received sequence id (debounced ~1s, mirroring the reference implementation). The sender then drops every buffered message with a sequence id ≤ the ack.

[8, 7] # "I have received through sequence id 7"

Sequence & resend​

When a transport reconnects, the reconnecting side first sends a Sequence message telling the peer the sequence id its replay will start at, then replays the buffer:

[9, 5] # "the next message you will see is sequence id 5"
[1, ...] # replayed invocation (seq 5)
[2, ...] # replayed stream item (seq 6)

Deduplication​

The receiving side tracks the sequence ids it has processed. When a transport reconnect happens it stops accepting trackable messages until a Sequence resets its counter; replay messages whose sequence id was already processed are dropped. This is what guarantees exactly-once delivery even when an Ack was lost on the wire.

The handshake​

Client Server
| negotiate?useStatefulReconnect=true |
|--------------------------------------->|
| {connectionToken, useStatefulReconnect: true, ...} |
|<---------------------------------------|
| WebSocket connect (?id=connectionToken) |
|--------------------------------------->|
| handshake {"protocol": "json", "version": 2} |
|--------------------------------------->|
| {} (handshake ok) |
|<---------------------------------------|

Only after the version-2 handshake does either side create its message buffer.

Server-side lifecycle​

  1. The WebSocket transport drops.
  2. The server keeps the connection record (and its buffer) alive for stateful_reconnect_timeout seconds.
  3. The client reconnects the WebSocket to the same URL (reusing the connectionToken).
  4. The server re-attaches the transport, sends Sequence + replays buffered messages.
  5. If the client does not return in time, the connection is torn down and on_disconnected fires.

Client-side lifecycle​

  1. The transport drops.
  2. The client marks the buffer as disconnected and stops accepting non-sequence messages.
  3. It reconnects the WebSocket to the saved connect URL (same token, no re-negotiation, no handshake).
  4. It sends Sequence + replays its own buffered messages.
  5. If the server replies within the server timeout, the connection continues; otherwise the client falls back to a normal reconnect (if a retry policy is configured) or closes.

Combining with automatic reconnect​

connection = (
HubConnection()
.with_stateful_reconnect()
.with_retry_policy(DefaultRetryPolicy())
)

If the stateful transport reconnect fails, the client falls back to the regular reconnect flow (which renegotiates and fails pending invocations).

Interop​

aiosignalr is tested against the reference implementation:

  • aiosignalr client ↔ real ASP.NET Core SignalR server (AllowStatefulReconnects = true).
  • real Microsoft.AspNetCore.SignalR.Client (with WithStatefulReconnect()) ↔ aiosignalr server.

Run them locally with:

uv run pytest tests/interop # requires dotnet

Design notes​

  • The shared MessageBuffer (aiosignalr.message_buffer) implements both directions and mirrors MessageBuffer.cs / MessageBuffer.ts from the reference implementation.
  • Broadcast fanout encodes once per protocol and feeds each recipient's buffer, so disconnected-but-alive clients still receive the message on reconnect.
  • Buffer overflow applies backpressure instead of dropping messages — at the cost of blocking the writer until an Ack frees space (bounded by 5s, then the write proceeds).