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).