GitHub

Troubleshooting & FAQ

Common issues and solutions when working with Vuer-RTC.

Messages Not Syncing

Problem: Operations are committed but not appearing on other clients.

Common causes:

WebSocket Connection Issues

  1. Check connection status:
const store = createGraph({
  sessionId: 'my-session',
  onSend: (msg) => {
    if (websocket.readyState !== WebSocket.OPEN) {
      console.error('WebSocket not open:', websocket.readyState);
      return;
    }
    websocket.send(JSON.stringify(msg));
  },
});
  1. Verify server is receiving messages:
// Server-side logging
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    console.log('Received:', data);
  });
});
  1. Test with the echo server:
# Run the test server
cd packages/vuer-rtc-server
pnpm start:echo

The echo server logs all messages to console. Use it to verify your client is sending properly formatted messages.

Missing Acknowledgements

Messages need to be acknowledged for compaction and snapshot creation.

Solution: Ensure server echoes messages back to all clients:

// Server-side: Echo to all clients in the room
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString());

    // Broadcast to all clients (including sender)
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(msg));
      }
    });
  });
});

Verify acknowledgements:

store.subscribe((state) => {
  const unacknowledged = state.journal.filter(entry => !entry.acked);
  if (unacknowledged.length > 10) {
    console.warn('Many unacknowledged messages:', unacknowledged.length);
  }
});

Messages remain unacknowledged until the server echoes them back. Without acks, snapshots cannot advance and the journal will grow indefinitely.

Text CRDT Producing Wrong Results

Problem: Collaborative text editing produces unexpected or corrupted output.

Root cause: Causal ordering violations.

Understanding the Issue

The Rope CRDT requires operations to be applied in causal order:

// Client A inserts at position 5
{ clock: { A: 1 }, ops: [{ otype: 'rope.insert', index: 5, text: 'hello' }] }

// Client B deletes from position 3 (depends on A's insert)
{ clock: { A: 1, B: 1 }, ops: [{ otype: 'rope.delete', index: 3, length: 2 }] }

Client B's operation has clock: { A: 1, B: 1 }, meaning it depends on A's operation (A: 1).

Solution: Buffer Out-of-Order Messages

const pendingMessages: CRDTMessage[] = [];

function receive(msg: CRDTMessage) {
  // Check if we have all dependencies
  if (!canApply(msg)) {
    pendingMessages.push(msg);
    return;
  }

  store.receive(msg);

  // Try to apply pending messages
  let applied = true;
  while (applied) {
    applied = false;
    for (let i = 0; i < pendingMessages.length; i++) {
      if (canApply(pendingMessages[i])) {
        store.receive(pendingMessages[i]);
        pendingMessages.splice(i, 1);
        applied = true;
        break;
      }
    }
  }
}

function canApply(msg: CRDTMessage): boolean {
  const currentClock = store.getState().clock;
  for (const [sessionId, time] of Object.entries(msg.clock)) {
    if ((currentClock[sessionId] || 0) < time - 1) {
      return false; // Missing dependency
    }
  }
  return true;
}

If you see text appearing in the wrong positions or deletions affecting the wrong characters, check message ordering. The vector clock in each message tracks dependencies.

Quick Test

// Log clocks to verify ordering
store.subscribe((state) => {
  console.log('Current clock:', state.clock);
});

websocket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  console.log('Received msg clock:', msg.clock);
  store.receive(msg);
};

Undo Not Working

Problem: store.undo() returns null or has no effect.

Common causes:

1. No Committed Messages to Undo

store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'cube' } });
const result = store.undo(); // null - nothing committed yet

store.commit('Add cube');
const result = store.undo(); // Works now

Only committed messages can be undone. Operations in the edit buffer are not yet in the journal.

2. Target Message Already Compacted

When a snapshot is created, older journal entries are compacted:

// Message was committed at index 10
store.commit('Add cube');

// After compaction, snapshot includes up to index 50
// Message at index 10 is removed from journal

store.undo(); // null - message no longer in journal

Solution: Adjust compaction threshold:

const store = createGraph({
  sessionId: 'my-session',
  onSend: (msg) => websocket.send(msg),
  compactionThreshold: 1000, // Keep more history (default: 100)
});

Higher compaction thresholds mean more memory usage. Balance undo history depth with memory constraints.

3. Message from Different Session

undo() only undoes messages from the current session:

// This session is 'session-A'
const store = createGraph({ sessionId: 'session-A', ... });

// Message from 'session-B' is received
store.receive({
  id: 'msg-123',
  sessionId: 'session-B', // Different session
  ops: [...]
});

store.undo(); // Won't undo session-B's message

To undo a specific message:

// Undo a specific message ID
store.edit({
  otype: 'meta.undo',
  targetMsgId: 'msg-123',
});
store.commit('Undo remote change');

Performance Degrading Over Time

Problem: Application becomes slower after extended use.

Root cause: Journal grows too large without compaction.

Diagnosis

store.subscribe((state) => {
  console.log('Journal size:', state.journal.length);
  console.log('Snapshot coverage:', state.snapshot.index);
});

If the journal has hundreds or thousands of entries, you need more frequent compaction.

Solution 1: Adjust Compaction Threshold

const store = createGraph({
  sessionId: 'my-session',
  onSend: (msg) => websocket.send(msg),
  compactionThreshold: 50, // Compact more frequently (default: 100)
});

Solution 2: Manual Compaction

// Force compaction after major operations
store.commit('Major change');
const state = store.getState();
if (state.journal.length > 200) {
  // Compaction happens automatically on next receive
  // when acked messages exceed threshold
}

Solution 3: Periodic Cleanup

setInterval(() => {
  const state = store.getState();
  const ackedCount = state.journal.filter(e => e.acked).length;

  if (ackedCount > 150) {
    // Trigger compaction by receiving a dummy message
    // (compaction runs during receive if threshold met)
  }
}, 60000); // Check every minute

Compaction bakes acknowledged journal entries into the snapshot and removes them from the journal. This keeps replay fast.

Type Errors in TypeScript

Problem: Import errors or missing types.

Incorrect Import Path

// ❌ Wrong - tries to import from package root
import { Rope } from '@vuer-ai/vuer-rtc';
// Error: Module '"@vuer-ai/vuer-rtc"' has no exported member 'Rope'

// ✅ Correct - use subpath export
import { Rope } from '@vuer-ai/vuer-rtc/rope';

Available Subpath Exports

// Core CRDT operations
import { createGraph, type CRDTMessage } from '@vuer-ai/vuer-rtc';

// React hooks
import { useGraph, useNode } from '@vuer-ai/vuer-rtc/react';

// Text CRDT
import { Rope, type RopeNode } from '@vuer-ai/vuer-rtc/rope';

// Server utilities
import { createCRDTServer } from '@vuer-ai/vuer-rtc/server';

Check package.json exports field for the complete list of available subpath exports.

Missing Type Definitions

If types are not resolving:

# Ensure TypeScript can find the package
pnpm install
pnpm exec tsc --traceResolution | grep vuer-rtc

Check your tsconfig.json:

{
  "compilerOptions": {
    "moduleResolution": "bundler",  // or "node16"
    "resolvePackageJsonExports": true
  }
}

State Divergence Between Clients

Problem: Clients show different states after the same operations.

Root cause: Clock synchronization issues or non-deterministic conflict resolution.

Verify Message Order

All clients must apply messages in the same causal order:

// Log applied messages
store.subscribe((state) => {
  console.log('Journal:', state.journal.map(e => ({
    id: e.msg.id,
    sessionId: e.msg.sessionId,
    clock: e.msg.clock,
  })));
});

Compare logs across clients - the journal order should be identical.

Check Last-Write-Wins Ordering

For *.set operations, lamportTime determines the winner:

// Client A (lamportTime: 100)
{ ops: [{ otype: 'scalar.set', key: 'cube', path: 'visible', value: true }] }

// Client B (lamportTime: 101) - wins
{ ops: [{ otype: 'scalar.set', key: 'cube', path: 'visible', value: false }] }

Solution: Ensure lamport time increments correctly:

const store = createGraph({
  sessionId: 'my-session',
  onSend: (msg) => {
    console.log('Sending lamport:', msg.lamportTime);
    websocket.send(msg);
  },
});

If lamport times are not monotonically increasing, conflict resolution will be inconsistent. Verify the server is not modifying lamport times.

Debugging Tool: State Hash

Create a deterministic hash of state for comparison:

import { createHash } from 'crypto';

function stateHash(store: GraphStore): string {
  const state = store.getState();
  const normalized = JSON.stringify({
    graph: state.graph,
    clock: state.clock,
  });
  return createHash('sha256').update(normalized).digest('hex');
}

// Log periodically
setInterval(() => {
  console.log('State hash:', stateHash(store));
}, 5000);

All clients should show the same hash after convergence.

Memory Leaks

Problem: Memory usage grows unbounded over time.

Root cause: Journal not being compacted.

Diagnosis

// Monitor memory usage
setInterval(() => {
  const state = store.getState();
  console.log({
    journalSize: state.journal.length,
    editBufferSize: Object.keys(state.edits).length,
    graphNodes: Object.keys(state.graph.children).length,
  });
}, 30000);

Common Causes

  1. Messages never acknowledged:
// Check ack status
const unacked = store.getState().journal.filter(e => !e.acked);
console.log('Unacked messages:', unacked.length);

Solution: Ensure server echoes messages back.

  1. Compaction disabled or threshold too high:
const store = createGraph({
  sessionId: 'my-session',
  onSend: (msg) => websocket.send(msg),
  compactionThreshold: 50, // Lower threshold
});
  1. Edits never committed:
// Edit buffer holding uncommitted operations
store.edit({ ... });
store.edit({ ... });
// Forgot to call commit()

// Solution: Always commit edits
store.commit('Changes');

Uncommitted edits stay in memory indefinitely. Make sure to call commit() after editing operations, especially in event handlers like onMouseUp or onDragEnd.

WebSocket Disconnections

Problem: Connection drops and messages are lost.

Implement Retry Logic

import { retryWithBackoff } from '@vuer-ai/vuer-rtc/networking';

function connectWithRetry(url: string): Promise<WebSocket> {
  return retryWithBackoff(
    () => {
      return new Promise((resolve, reject) => {
        const ws = new WebSocket(url);
        ws.onopen = () => resolve(ws);
        ws.onerror = (err) => reject(err);
      });
    },
    {
      maxRetries: 5,
      baseDelay: 1000,
      maxDelay: 30000,
      backoffFactor: 2,
    }
  );
}

// Usage
const ws = await connectWithRetry('ws://localhost:8080');

Reconnection with Message Replay

Keep a buffer of sent messages for replay:

const sentMessages: CRDTMessage[] = [];

const store = createGraph({
  sessionId: 'my-session',
  onSend: (msg) => {
    sentMessages.push(msg);
    websocket.send(JSON.stringify(msg));
  },
});

websocket.onclose = async () => {
  console.log('Disconnected, reconnecting...');
  const newWs = await connectWithRetry('ws://localhost:8080');

  // Replay unacknowledged messages
  const unacked = sentMessages.filter(msg => {
    const entry = store.getState().journal.find(e => e.msg.id === msg.id);
    return entry && !entry.acked;
  });

  unacked.forEach(msg => {
    newWs.send(JSON.stringify(msg));
  });
};

The retry utilities in @vuer-ai/vuer-rtc/networking provide exponential backoff and jitter to avoid thundering herd problems.

Heartbeat to Detect Disconnections

let heartbeatInterval: NodeJS.Timeout;

websocket.onopen = () => {
  // Send ping every 30 seconds
  heartbeatInterval = setInterval(() => {
    websocket.send(JSON.stringify({ type: 'ping' }));
  }, 30000);
};

websocket.onclose = () => {
  clearInterval(heartbeatInterval);
};

websocket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'pong') {
    // Connection alive
    return;
  }
  store.receive(msg);
};

Still Having Issues?

Open an issue on GitHub with:

  • Minimal reproduction
  • Client and server code
  • Console logs showing the issue
  • Expected vs actual behavior

Next Steps