PolynomialHQdocs
Guides

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:

  1. Confirm the browser can reach the configured authEndpoint.
  2. Inspect the endpoint response. It should return JSON containing a token.
  3. Confirm projectId and roomId in the signed token match the room being entered.
  4. Verify the secret key begins with sk_ and is available to the server process.
  5. Read useConnectionSnapshot().lastError or 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

  • 401 means the request has no valid application session. Confirm cookies or authorization headers reach the endpoint.
  • 403 means 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 joined connection state;
  • supplied valid initialPresence;
  • render useOthers() from inside the matching RoomProvider.

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 write or admin room 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.

On this page