Agora

Troubleshooting

The symptoms that actually happen — stuck connections, silent divergence, disappearing edits, empty presence, restores that do nothing — and how to tell the causes apart quickly.

Sorted by how often they come up.

The client never leaves connecting

Almost always authorization, and almost always a pattern that does not match.

Check in this order:

  1. Call authorize directly with the exact document name the client asked for. The manager resolves the same way the handshake does:

    const permission = await collaboration.current.authorize({ userId: '42' }, 'researches/42/writing')
    console.log(permission) // { canRead: false, ... } → this is your bug
  2. Check the pattern's segment count. The matcher is exact: researches/:id/writing matches researches/42/writing and not researches/42/writing/draft. A different shape needs its own entry.

  3. Confirm you wrote a rule at all. No matching pattern and no global authorize means deny — by design, and silently.

  4. Check the token response. GET /collaboration/token?doc=… returning 403 confirms authorization; 401 means no user was resolved (your auth middleware did not run, or routes.resolveUser needs configuring); 400 means the doc param is missing.

A declared document's engine or authorize never applies

The document opens, but with the global rule and the global engine — a whiteboards entry declared as Automerge comes up as Yjs, or its authorize never runs.

Look at the pattern's key: it probably has no :segment.

documents: {
  'whiteboards': { engine: 'automerge' },   // matches ONE document, named "whiteboards"
}

A pattern is matched segment by segment, and only a :segment accepts anything — there is no prefix match. 'whiteboards' is the single document literally named whiteboards; whiteboards/42 has two segments, does not match, and falls through to engineFor, engine and the global authorize. The manager logs a warning at boot for every declared pattern with no :segment — check the log for has no ":param" segment.

The fix is the :id, and the safest way to write it is the helper that cannot be written without one:

documents: {
  ...defineCollection('whiteboards', { engine: 'automerge' }),   // declares "whiteboards/:id"
}

For a shape that is not <prefix>/<id>researches/:id/writing, two params — write the pattern out, :segment included. See Documents.

The editor opens empty on a document whose text is in the database

The document is new to the library — nothing has ever been stored under that name — so the CRDT document it hands the editor is empty, and the editor shows what the CRDT says. The row in your database is not consulted by anyone.

This is worth treating as urgent rather than cosmetic: the editor's autosave writes what it is showing, so the next flush replaces the real text with the empty one. It is a deletion with a blank screen in front of it.

Declare a load on the document, and the first client to open it gets the content already in it:

documents: {
  'researches/:id/writing': {
    async load({ params }) {
      const research = await Research.find(params.id)
      return research?.body ? TiptapTransformer.toYdoc(research.body, 'default') : null
    },
  },
}

Seeding it yourself from the controller that renders the page works right up until something opens the socket without going through that page. See Documents.

If a load is declared and the document still opens empty, check the logs for a storage.loadDocument failed report: a hook that throws is reported and the document opens empty on purpose, because failing the handshake instead would only make the client reconnect and run the failing query again.

401 from the token endpoint on a request that is clearly logged in

routes.resolveUser defaults to reading ctx.auth.user, which is only populated when @adonisjs/auth's middleware ran on that route. Auto-registered routes get no middleware unless you give them some:

config/collaboration.ts
routes: {
  middleware: [middleware.auth()],
  // or, if your user is not at ctx.auth.user:
  resolveUser: (ctx) => ({ id: String(ctx.currentUser.id), name: ctx.currentUser.name }),
}

Since 0.13.0 the default resolver also calls check()/authenticate() itself, and then asks getUser()/getUserOrFail() when ctx.auth.user is still empty — which is what auth stacks that resolve the user on demand (@adonis-agora/authkit) need, since they verify the session and never populate user at all. If you wrote a resolveUser that only called getUserOrFail(), you no longer need it.

Coming back to the page gives a blank editor stuck on connecting

Fixed in 0.10.0 — upgrade. A session used to be destroyed in useCollabDoc's effect cleanup while the provider's map kept it, so an Inertia navigation back to the page (or a React StrictMode remount) handed the component the destroyed session, whose start() short-circuited on its cached promise. No error, no reconnect, an empty document.

Sessions are reference counted now: the last unmount tears down the transport and keeps the Y.Doc. If you see this after upgrading, look for your own session.destroy() in a component — destroy() is terminal by design and does not belong in an effect cleanup.

The editor goes read-only whenever the network hiccups

Something is gating editable on the connection — editable: status === 'connected' is the usual line. Remove it. 'connected' means the socket is open, not that the document arrived, so it never solved the empty-document problem it was written for, and a four-second blip silently locks the editor mid-sentence.

Gate the initial render on synced instead and leave editable alone; use canWrite from your authorize result for a genuinely read-only user. See Sessions.

Everyone in the avatar stack is anonymous

Nobody is publishing an identity. peers is built from the user field of the other clients' awareness state, so if no one sets it, every client sees a room of bare clientIds. Pass user to useAwareness (or call setLocalState) in every tab:

const { peers } = useAwareness({
  docName,
  user: { userId: me.id, name: me.name, avatarUrl: me.avatarUrl },
})

Whatever you last published is re-applied after a reconnect, so you do not vanish from other people's lists on a dropped socket.

Edits from another user never arrive

The document is open in more than one process. See Production — you need sticky routing keyed on the document name, one collaboration instance, or an edge engine.

Quick confirmation: scale to one instance. If the problem disappears, that was it.

Routing by client IP or session is not enough. Two users editing the same document are two different clients and will be routed independently.

Edits are lost on deploy or restart

The last debounce window never flushed. Two causes:

  • storage is not configured. Everything went to memory and evaporated. There is no warning — check your config first.
  • The shutdown was not graceful. A SIGKILL skips the flush. Raise the termination grace period so the process has time to finish.

Restoring a version changes nothing

loadVersionSnapshot is returning the current document instead of the version's bytes. With the bundled storages that does not happen; with a custom backend it is the classic mistake, because there is no error — restore succeeds and the document is unchanged, and diffVersions reports { added: 0, removed: 0 } between any two versions.

// The test that catches it
await storage.saveDocument('doc/1', bytesA)
await storage.saveVersion('doc/1', version, bytesA)
await storage.saveDocument('doc/1', bytesB)
assert.deepEqual(await storage.loadVersionSnapshot('doc/1', version.id), bytesA) // not bytesB

useCollabDoc reports an engine error

The server resolved this document to automerge, which cannot back a Y.Doc. The session does not throw — it surfaces the failure on the hook's error and leaves status at 'error', so read error rather than wrapping the hook in a try. Use useAutomergeDoc — or fix the routing if the document was meant to be Yjs. Check what the server actually decided:

await collaboration.current.engineFor({ docName })

Remember that engineFor in config overrides per-document engine declarations, so a broad function can quietly capture a document you thought was declared.

listPresence always returns an empty array

redisUrl is not set. Without it there is no server-side roster and the call returns [] rather than throwing. Set it, or use useAwareness if the question can be answered from the browser.

The presence roster shows fewer people than are in the document

Upgrade to 0.10.0 or later. Two separate defects produced this: the presence store the provider built from redisUrl was never passed down to the driver (so join/leave were dead code and listPresence returned [] forever), and the driver's bookkeeping held one slot per document rather than per connection, so a second editor overwrote the first and a disconnect could evict the wrong person. Both are fixed; the roster is keyed by socket.

A save failed and nothing said so

Nothing swallows it any more, but you have to be listening. Storage and authorize failures go to the Adonis logger when the provider could resolve one, and are always emitted as an event:

start/collaboration_observability.ts
import { onCollaborationError } from '@adonis-agora/collaboration'

onCollaborationError(({ scope, operation, docName, error }) => {
  // scope: 'storage' | 'authorize'
  reportToSentry(error, { scope, operation, docName })
})

With neither a logger nor a listener the failure still reaches console.error — an unobserved outage is the one outcome this does not produce.

Two distinctions worth knowing when you read those events. A denial and an outage are different things: authorize returning canRead: false is a CollabForbiddenError, while authorize throwing — the database it queries is down — is a CollabAuthorizationError and is reported here under scope: 'authorize'. And on the Automerge driver the two reach the browser as different WebSocket close codes: 4003 for a denial (final; do not retry) and 4503 for "could not evaluate the rule" (transient; back off and retry). Collapsing them is how an app-side database blip becomes a reconnect storm.

Undo removes someone else's text

ProseMirror's history is still enabled. StarterKit.configure({ undoRedo: false }) — see Tiptap collaboration. It only reproduces with two people in the document, which is why it usually reaches production.

If you wrote history: false and nothing changed: that is the Tiptap 2 name. Tiptap 3 renamed the StarterKit option to undoRedo, and an unknown key is ignored rather than rejected — so the undo extension stays on and the collision looks unfixable.

Constant disconnect/reconnect cycling

A proxy is closing the idle WebSocket. Raise proxy_read_timeout (nginx), the idle timeout (ALB), or the equivalent — the reconnect loop is the library correctly recovering from a socket someone else closed.

If it cycles every few seconds rather than every few minutes, look at the token instead: a 403 on reissue after a permission change produces the same shape.

A read-only user can still edit

Their edits are being dropped, but the UI never told them. canWrite: false makes the connection read-only — the server ignores their updates while still sending them everyone else's, so locally the text moves and then snaps back.

That is the enforcement working; what is missing is the UI. Gate the editor on the permission (editable={canWrite}) so the user is not offered an action that will be refused.

Comments do not appear for another user

They are REST-backed and not live — they load on mount and on refresh(). Trigger a refresh from whatever signal you already have; see Comments & versions.

Column-not-found errors from the storage

The tables were created by a schema that does not match the one the storage queries — usually migrations from an older install. Compare the live schema against the published migrations and reconcile; the table names are collab_documents, collab_versions and collab_comments.

When nothing here fits

Reduce to the smallest failing case: one process, in-memory storage, an authorize that returns { canRead: true, canWrite: true, canComment: true }, two tabs. If that works, add back one piece at a time — storage, then authorization, then the second instance. The layer where it breaks is the answer.

On this page