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:
-
Call
authorizedirectly 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 -
Check the pattern's segment count. The matcher is exact:
researches/:id/writingmatchesresearches/42/writingand notresearches/42/writing/draft. A different shape needs its own entry. -
Confirm you wrote a rule at all. No matching pattern and no global
authorizemeans deny — by design, and silently. -
Check the token response.
GET /collaboration/token?doc=…returning403confirms authorization;401means no user was resolved (your auth middleware did not run, orroutes.resolveUserneeds configuring);400means thedocparam 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:
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:
storageis not configured. Everything went to memory and evaporated. There is no warning — check your config first.- The shutdown was not graceful. A
SIGKILLskips 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 bytesBuseCollabDoc 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:
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.
Production
The operational checklist — why documents are process-bound and what sticky routing has to guarantee, tuning the debounce against data loss, Redis presence, the failure stream, shutdown, secrets, growth and backup.
API reference
Every config option, manager method, exported helper, type, route and client hook — from source.