Skip to main content

Troubleshooting

The failure modes you will actually hit with rooms, roughly in order of likelihood.

Indexer (Photon) lag

Rooms live in compressed accounts, so every read goes through the Photon indexer — which can run a moment behind the chain. Typical symptoms right after a write:

  • myMember() throws "is not a member" seconds after addMember succeeded (or still shows status: 0 right after a removal);
  • send() returns message: null (the receipt is valid — the account just isn't indexed yet);
  • loadMessages() is missing the newest message;
  • key recovery fails with "room header at epoch N not found (after indexer-lag retries)".

What the SDK already does for you:

  • Header/page reconstruction retries internally (5 × 400 ms by default). You can tune it: room.reconstructRecipientState(epoch, { retry: { retries: 10, delayMs: 600 } }).
  • send() retries fetching the just-sent message (3 × 400 ms) before returning message: null. When it does return null, the message handle still works: await room.message(result.clientMsgId).loadRetrying().
  • RoomMessageClient.loadRetrying(retries?, delay?) for any single message.

For everything else, wrap reads of freshly written state in a bounded retry:

async function withRetry<T>(fn: () => Promise<T>, retries = 10, delayMs = 600): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < retries; i++) {
try { return await fn(); } catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, delayMs));
}
}
throw lastErr;
}

// e.g. right after the admin added this wallet:
const me = await withRetry(() => room.myMember(true)); // force = bypass the cache

Remember the caches when polling: myMember(true), loadBucket(b, { force: true }), loadMessages({ force: true }).

MutationRequiresHeader

The program serializes membership changes: a new add/remove is rejected with MutationRequiresHeader until the previous mutation has been covered by a published header. addMember/removeMember send both transactions for you, so in normal operation you never see this.

You will see it (or the SDK's pre-flight version of it: "the previous membership mutation has not been covered by a header yet") when an earlier mutation tx landed but its header tx didn't — a crash or network failure between the two. The original failure error spells out the recovery; it is always:

import { RecipientDeltaOp } from "xpkt-sdk";

await adminRoom.publishMutationHeader({
mutation: { op: RecipientDeltaOp.Activate /* or .Remove */, slot: pendingSlot },
});

To diagnose which mutation is pending, compare adminRoom.Room.memberVersion (mutations applied) with adminRoom.Room.latestHeaderMemberVersion (mutations covered) after a refresh() — a difference of 1 means exactly one uncovered mutation.

Related: "a staged header publication is open for this room" means an external-checkpoint publication (large rooms) was interrupted mid-staging. Finish it with publishReuseHeader() / publishMutationHeader(...) — re-publishing restarts the staged pages and completes the epoch.

Concurrent sends from the same member

Every send updates the sender's RoomMember compressed account (per-member send guard), so two in-flight sends from one wallet conflict — the second lands on a stale account state and the transaction fails. Symptoms: intermittent send failures under load that disappear when you slow down.

  • Serialize sends per wallet — await each send() before starting the next, or run an explicit per-room queue:
let sendChain: Promise<unknown> = Promise.resolve();
function sendSerialized(params: { text: string }) {
const next = sendChain.then(() => room.messages().send(params));
sendChain = next.catch(() => {});
return next;
}
  • Different members sending concurrently is fine — the room PDA orders them on-chain.

Params mismatch

Every room pins the BGW params identity at creation; clients must load the identical artifact.

ErrorCauseFix
BGW params mismatch for room ...: the resolved params root does not match the room's params_root (RoomAdminClient.Load)Wrong artifact configured for this roomPoint configureDefaultBgwParams (or the per-room bgwParams override) at the artifact the room was created with
No BGW params available. Either pass a BgwParamsClient explicitly ...No default configured and no override passedCall configureDefaultBgwParams(...) at startup
BGW params chunk N hash mismatch / byteLength mismatchCorrupt or partially downloaded artifactRe-download / re-host the artifact
Reader-side decrypt/decapsulation failures with otherwise healthy stateReader loaded a different artifact than the room's (RoomClient.Load does not pre-verify the root)Compare await params.getParamsRoot() with room.Room.paramsRoot

One process serving rooms with different params must pass the per-room bgwParams override — the process-wide default is keyed by source, not by room.

Expected results that are not errors

  • recoverEpochKey(...){ status: "not-recipient" } — this wallet isn't in that epoch's recipient set (removed member at a newer epoch; re-added member at an epoch from their gap; new member at a pre-join epoch). Render it, don't retry it.
  • decrypt(){ status: "locked" } — same condition, per message. History before a member joined is supposed to stay locked.
  • decrypt(){ status: "failed" } — this one is abnormal (tampered/corrupt content, or a transient key-recovery failure; failed results aren't cached, so calling decrypt() again retries).
  • A removed member keeps decrypting messages from before their removal — by design; they already had those keys.