Troubleshooting
Diagnose authentication, connection, presence, event, and storage problems.
Start with the symptom you can see. Most integration problems come from the token endpoint, a room ID mismatch, or rendering before the room has finished loading shared state.
The room never connects
Check these in order:
- Confirm the browser can reach the configured
authEndpoint. - Inspect the endpoint response. It should return JSON containing a
token. - Confirm
projectIdandroomIdin the signed token match the room being entered. - Verify the secret key begins with
sk_and is available to the server process. - Read
useConnectionSnapshot().lastErroror subscribe to room errors.
const connection = useConnectionSnapshot();
if (connection.lastError) {
return <p>Could not join this room: {connection.lastError.message}</p>;
}Do not expose the raw error to end users if it contains internal identifiers. Log the detailed error on the server and show a short recovery message in the interface.
createClient requires authEndpoint, publicApiKey, or an enterRoom token.
PolynomialProvider creates a default client when you omit the client prop, and a default client
has no credentials. Either pass a configured client, or supply a token in the RoomProvider
options form. See the quickstart.
The token endpoint returns 401 or 403
401means the request has no valid application session. Confirm cookies or authorization headers reach the endpoint.403means the user is signed in but your application denied this room. Log the requested room ID and the permission decision.
Never work around either response by moving the secret key into browser code.
People cannot see one another
Presence is scoped to an exact room ID. Confirm both clients:
- joined the same case-sensitive room ID;
- reached the
joinedconnection state; - supplied valid
initialPresence; - render
useOthers()from inside the matchingRoomProvider.
A connection appears only after it publishes presence
initialPresence is not broadcast automatically on the first join. A collaborator shows up in
useOthers() once they call updatePresence() at least once. If your interface must list people
before they interact, publish presence on mount:
const updateMyPresence = useUpdateMyPresence<Presence>();
const status = useRoomStatus();
useEffect(() => {
if (status === "joined") {
updateMyPresence({ name: currentUser.name });
}
}, [currentUser.name, status, updateMyPresence]);Remember that useOthers() excludes the current connection. Use useSelf() when you also need
the local participant.
A reaction or sound fires twice for the sender
Broadcasts are delivered to every connection in the room, including the sender. Compare
event.connectionId with useConnectionSnapshot().connectionId and ignore your own events, or
apply the effect only when the echo arrives. See Broadcasts.
Broadcasts are missing
Broadcasts are transient. A connection that joins after an event was sent will not receive it, and an event sent while a client is reconnecting is lost for that client. Check that:
- the sender has a
writeoradminroom role; - the listener mounted before the event was sent;
- the sender and listener use the same event name;
- the payload matches the event map;
- both connections are in the same room.
Use durable storage when late joiners need the value.
The room drops to error after a write
A read token may join and publish presence, but the service rejects broadcasts and storage
writes with a non-retryable forbidden error, which ends the connection. Issue a write token for
interfaces that can edit, and hide write controls when the current role is read:
const { role } = useConnectionSnapshot();
const canEdit = role === "write" || role === "admin";A read client fails immediately in a brand-new room
When a room has no persisted document, the first connection seeds initialStorage by writing it
into the shared document — and a read token is not allowed to write. A read-only viewer that is
the first connection to an empty room therefore joins and then fails with forbidden.
Create the room's initial document from a connection that can write: the author's session, or a server-side setup step before read-only viewers are pointed at it. Rooms that already contain a document are unaffected, because no seed write happens.
An early edit disappears
A mutation issued before the first yjs.sync arrives is held only in the local document. If a
second client is seeding the same empty room at the same moment, the two seeds race and one side's
content is dropped — silently, with a final status of synchronized.
Wait for storage to load before allowing writes:
const storageStatus = useStorageStatus();
const ready = storageStatus === "synchronized" || storageStatus === "synchronizing";This only affects the window between joined and the first yjs.sync, and only when two clients
open the same brand-new room at once. Once a room has a document, later writes converge normally.
Storage stays on “loading”
First confirm the room has joined. Storage cannot finish loading without an active room
connection. Then inspect useStorageStatus() and the room's last connection error.
If the room already contains persisted state, that state replaces initialStorage. Treat
initialStorage as a seed for an empty room—not a forced reset.
A mutation does not update the UI
- Call storage methods inside
useMutation()or a room mutation. - Return a new array or object from
updateAt()instead of mutating the existing value. Reads return clones, so mutating them in place is always a no-op. - Verify the
useStorage()selector reads the same path the mutation updates. - Keep selector output focused; selecting the entire document makes changes harder to reason about.
const title = useStorage<Presence, Storage, Events, string>(
(storage) => storage.title,
);
const rename = useMutation<Presence, Storage, [string]>(
({ storage }, nextTitle) => {
storage.setAt(["title"], nextTitle);
},
[],
);A mutation throws TypeError
Storage accepts only JSON-representable values. undefined, NaN, Infinity, Date, Map,
Set, and class instances throw. Convert them first — an ISO string for a date, an array of
entries for a map.
Writing through a primitive also throws: if storage.title is a string, setAt(["title", "x"], 1) cannot create an object underneath it.
Undo or redo is unavailable
History tracks durable storage mutations. Presence updates and broadcasts do not create history
entries. Read useCanUndo() and useCanRedo() before enabling controls.
Every write inside one useMutation() call collapses into a single history entry, so grouping
related writes into one mutation is how you control the granularity of undo. RoomHistory has no
pause or resume — the mutation boundary is the only grouping mechanism.
Reconnects feel disruptive
Keep the last rendered document visible while the SDK reconnects. Use
useLostConnectionListener() for transition messages and useSyncStatus() for a compact saved
indicator. See Connection and reconnection for the full pattern.
Server management calls fail
Catch PolynomialApiError and inspect its status:
import { PolynomialApiError } from "@polynomialhq/server";
try {
await polynomial.getRoom({ projectId, roomId });
} catch (error) {
if (error instanceof PolynomialApiError) {
console.error("Room lookup failed", {
roomId,
status: error.status,
});
}
}A 404 usually means the room does not exist in that project. Authentication failures point to
the server secret or project configuration.
Token signing and verification throw RoomTokenError instead — including when the secret key does
not begin with sk_, which is checked before any network call.
Still stuck?
Reduce the integration to one room, one RoomProvider, and one small presence or storage value.
Once that works, add your application model back one piece at a time. The
React quickstart is a useful known-good reference.