Learn
Reconnect, replay, and Durable Object hibernation
When a Room DO hibernates, WebSockets drop and wake again. FluxyChat’s SDK handles the client experience: backoff, connectionState, and history replay via loadMore.
Reconnect, replay, and Durable Object hibernation
When a Room DO hibernates, WebSockets drop and wake again. FluxyChat’s SDK handles the client experience: backoff, connectionState, and history replay via loadMore.
What happens on the server
Durable Objects can hibernate idle WebSocket connections to save resources. When clients reconnect, the DO accepts a new upgrade and resumes fan-out. Messages sent while a client was offline are not magically replayed over the socket alone.
That is why production chat pairs live WebSocket events with persisted history in D1.
Crash loops and sealed rooms
A room Durable Object can wake into a state it cannot hydrate (in practice: a boot that runs out of memory). The DO guards itself: every boot leaves a marker, and a boot that never completes is counted as a crash. After two consecutive crash boots the room seals itself and purges its storage, so it cannot die on the same state forever.
A sealed room:
- closes WebSocket connections with code
1011, reasonroom_sealed(SDKonDisconnectreportsreason: "server_error",reasonText: "room_sealed"); - refuses stream operations (
503 { error: "room_sealed" }) and skips announcements and alarms (no agent schedules); - keeps serving reads, D1 history, and REST writes — messages still persist, nobody receives them live.
# Status — any room member
curl "$WORKER_URL/rooms/$ROOM_ID/sealed" -H "Authorization: Bearer $JWT"
# Recover — owner/admin. Clears the seal; purged DO storage is NOT restored
# (use PITR snapshots at /rooms/{id}/pitr if you need the pre-crash state).
curl -X POST "$WORKER_URL/rooms/$ROOM_ID/sealed" -H "Authorization: Bearer $ADMIN_JWT"Reconnect storms (backoff + jitter)
computeReconnectBackoffMs is on by default in @fluxy-chat/sdk (FluxyChatRoomConnection.scheduleReconnect). Exponential cap is 20s from a 500ms base. Equal jitter (delay * (0.5 + random()/2)) spreads retries so a Worker or DO blip does not slam one room with every client at 500ms, 1s, 2s.
GET /client/feature-flags can advertise reconnectBackoff: { baseBackoffMs, maxBackoffMs }. Optional PartySocket transport already uses reconnectionDelayGrowFactor: 1.5 (1s–30s). Auth close 1008 does not retry.
What the SDK does for you
- connectionState.status: connected, connecting, reconnecting, disconnected.
- connectionState.nextRetryAt: show "Reconnecting in 3s…" in your UI.
- connectionState.transport: websocket, sse, or polling when WS is blocked.
- loadMore(): paginate older messages after refresh or reconnect.
- clientMessageId: idempotent retries for sends that failed mid-flight.
- deliveryStatus per message: pending, sent, failed with retryMessage().
Typed disconnect reasons
onDisconnect fires on every socket close with a typed reason; the last one stays on
connection.disconnectReason:
const conn = client.connectRoom(roomId, {
onDisconnect: ({ reason, reasonText, willReconnect, code, error }) => {
// reason: FluxyDisconnectReason · reasonText: raw server text (e.g. terminated_by_moderator)
if (!willReconnect) showToast(`Disconnected: ${reason}`, error);
},
});| reason | meaning | reconnects? |
|---|---|---|
client_initiated | your code called close() | no |
network | dropped connection (1005/1006) | yes |
server_error | DO/Worker restart or setup failure (1001, 1011–1014) | yes |
heartbeat_timeout | no pong within heartbeatTimeoutMs (close 4000) | yes |
removed | a moderator/admin terminated this socket (close 4001; reasonText keeps the raw value) | yes |
token_expired, not_member, anonymous_not_allowed, channel_at_capacity, banned | terminal refusals (close 1008) | no |
protocol_unsupported | client protocol newer than the server | no |
auth_failed | other 1008 refusals (Unauthorized, policy) | no |
reconnect_failed | attempts exhausted (pairs with onReconnectFailed) | no |
unknown | unrecognized code — inspect code / reasonText | — |
onDisconnect can fire more than once per connection (once per dropped socket); a
willReconnect: false entry is terminal. Raw mapping is exported too:
mapWebSocketCloseToDisconnectReason(code, reasonText).
JWT refresh before expiry (partysocket-style)
Long-lived sessions need the JWT refreshed before exp to avoid a 1008 close mid-session. The SDK passes the token on the WS URL. Re-connecting with a fresh JWT keeps the user online without an explicit reconnect trigger.
- Use
decodeFluxyJwtPayload(token)andjwtRefreshDelayMs(payload)(from@fluxy-chat/sdk) to schedule a refresh ~60s beforeexp. - On
onAuthError(close 1008), mint a new JWT server-side and callclient.connectRoom(roomId, …)again with the new token. The previousFluxyChatRoomConnectioninstance is already in the disconnected state, so the new connect is a clean re-handshake. - The room DO does not store the JWT. Every connect re-validates the token + room membership, so token rotation is safe.
React example (useChat)
import { FluxyChatClient } from "@fluxy-chat/sdk";
import { useChat } from "@fluxy-chat/react";
const client = new FluxyChatClient({ baseUrl, userId, token });
function Room({ roomId }: { roomId: string }) {
const { messages, sendMessage, connectionState, loadMore, hasMore, retryMessage } =
useChat({ roomId, client });
return (
<div>
<p>
{connectionState.status}
{connectionState.nextRetryAt
? ` · retry ${connectionState.nextRetryAt}`
: null}
</p>
{hasMore ? (
<button type="button" onClick={() => void loadMore()}>
Load older messages
</button>
) : null}
{messages.map((m) => (
<div key={m.id}>
{m.content}
{m.deliveryStatus === "failed" ? (
<button type="button" onClick={() => retryMessage(m.clientMessageId!)}>
Retry
</button>
) : null}
</div>
))}
</div>
);
}Vanilla / Vue / Solid (createFluxyRoomSession)
import { FluxyChatClient, createFluxyRoomSession } from "@fluxy-chat/sdk";
const client = new FluxyChatClient({ baseUrl, userId, token });
const { store, stop } = createFluxyRoomSession({ roomId: "support-1", client });
store.subscribe((state) => {
console.log(state.connectionState, state.messages.length);
});
// After reconnect: store.getState().loadMore()
// Cleanup: stop();Pusher Channels alternative for SaaS chat
Pusher's free tier works until it doesn't. If connection pricing is climbing faster than your app, room chat on Cloudflare with optional self-host is worth a look.
Session security: storage, refresh, and token lifecycle
Best practices for managing FluxyChat sessions: secure token storage, refresh strategies, and preventing session fixation.