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
- The WebSocket transport drops.
- The server keeps the connection record (and its buffer) alive for
stateful_reconnect_timeoutseconds. - The client reconnects the WebSocket to the same URL (reusing the
connectionToken). - The server re-attaches the transport, sends
Sequence+ replays buffered messages. - If the client does not return in time, the connection is torn down and
on_disconnectedfires.
Client-side lifecycle
- The transport drops.
- The client marks the buffer as disconnected and stops accepting non-sequence messages.
- It reconnects the WebSocket to the saved connect URL (same token, no re-negotiation, no handshake).
- It sends
Sequence+ replays its own buffered messages. - 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(withWithStatefulReconnect()) ↔ 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 mirrorsMessageBuffer.cs/MessageBuffer.tsfrom 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
Ackfrees space (bounded by 5s, then the write proceeds).