FluxyChat

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, reason room_sealed (SDK onDisconnect reports reason: "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);
  },
});
reasonmeaningreconnects?
client_initiatedyour code called close()no
networkdropped connection (1005/1006)yes
server_errorDO/Worker restart or setup failure (1001, 1011–1014)yes
heartbeat_timeoutno pong within heartbeatTimeoutMs (close 4000)yes
removeda moderator/admin terminated this socket (close 4001; reasonText keeps the raw value)yes
token_expired, not_member, anonymous_not_allowed, channel_at_capacity, bannedterminal refusals (close 1008)no
protocol_unsupportedclient protocol newer than the serverno
auth_failedother 1008 refusals (Unauthorized, policy)no
reconnect_failedattempts exhausted (pairs with onReconnectFailed)no
unknownunrecognized 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) and jwtRefreshDelayMs(payload) (from @fluxy-chat/sdk) to schedule a refresh ~60s before exp.
  • On onAuthError (close 1008), mint a new JWT server-side and call client.connectRoom(roomId, …) again with the new token. The previous FluxyChatRoomConnection instance 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();

On this page