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.