Skip to content

Fix: Liveblocks Not Working — Room Not Connecting, Presence Not Syncing, or Storage Mutations Lost

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Liveblocks issues — room setup, real-time presence with useOthers, conflict-free storage with useMutation, Yjs integration, authentication, and React suspense patterns.

The Problem

The room never connects and presence is empty:

const others = useOthers();
console.log(others);  // [] — always empty even with multiple tabs open

Or storage mutations don’t persist:

const updateTitle = useMutation(({ storage }) => {
  storage.get('document').set('title', 'New Title');
}, []);
// Mutation runs but other clients don't see the change

Or the connection drops with an authentication error:

Error: Liveblocks: Could not connect to room "my-room" — authentication failed

Or useStorage returns null forever:

const document = useStorage((root) => root.document);
// null — never resolves, Suspense fallback shown indefinitely

Why This Happens

Liveblocks provides real-time collaboration through WebSocket rooms. Each room has presence (ephemeral per-user data) and storage (persistent, conflict-free shared state), and most failure modes fall into one of four categories. The room must be explicitly entered: useOthers, useStorage, and useMutation only work inside a RoomProvider. Without it, hooks return default/empty values without throwing errors, which masks the underlying setup mistake. Authentication is required in production: the public API key works for development, but production deployments need a backend endpoint that returns a signed Liveblocks token. Without proper auth, the WebSocket connection fails immediately after the upgrade handshake.

Storage must be initialized through the initialStorage prop on RoomProvider or via a mutation. useStorage reads from Liveblocks’ CRDT root, which starts empty. If storage is never initialized, reads return null forever and Suspense fallbacks hang. Mutations use a specific CRDT API: Liveblocks storage uses LiveObject, LiveList, and LiveMap types. Plain JavaScript mutations (obj.key = value) don’t work. You must use the CRDT methods (.set(), .push(), .delete()) inside useMutation — the wrapper provides the transactional context that synchronises with the server.

A fifth category that catches almost every team eventually is room ID drift. The room ID on the client must match exactly the room ID the auth endpoint authorises. Trailing slashes, encoding differences (room-1 vs room%2D1), or environment-specific prefixes (dev-room-1 vs prod-room-1) cause silent 403s where the WebSocket connects, fails auth, then disconnects, then retries forever. Always log the room ID on both sides before debugging anything else.

Platform and Environment Differences

WebSocket reachability is the single biggest production variable. Liveblocks uses a long-lived WebSocket connection to its edge servers (wss://api.liveblocks.io/v7/). Corporate proxies, Zscaler, Palo Alto firewalls, and government networks frequently block WebSocket upgrades on non-standard ports or reject the Sec-WebSocket-Protocol headers Liveblocks sets. The client falls back to polling after several failed connection attempts, which manifests as 5-10 second presence latency. Test with wscat -c wss://api.liveblocks.io/v7/ from the failing network before blaming the SDK.

Mobile WebView limits matter for embedded Liveblocks views in React Native WebView, Capacitor, or Cordova. iOS WKWebView throttles background WebSocket connections aggressively — after 30 seconds in background the connection drops and presence shows the user as offline. Android WebView is more lenient but Chrome’s data-saver mode (Lite mode) strips WebSocket headers. Always show a useStatus()-driven indicator in mobile WebView contexts so users know when they’re disconnected.

Cloudflare versus Vercel edge integration changes how you wire the auth endpoint. On Vercel Edge Runtime, @liveblocks/node works only with the experimental-edge runtime flag — the default Node.js runtime is fine but slower at cold start. On Cloudflare Workers, you cannot use @liveblocks/node directly because it depends on Node’s crypto module; use the Web Crypto-based REST API instead (fetch('https://api.liveblocks.io/v2/authorize-user', { ... })). The signing logic is identical, but the runtime imports differ.

Next.js App Router versus Pages Router changes the auth endpoint shape entirely. App Router uses app/api/liveblocks-auth/route.ts exporting a POST handler that returns a Response. Pages Router uses pages/api/liveblocks-auth.ts exporting a default function that calls res.send(). Mixing them produces a 405 Method Not Allowed at the auth step. Also, 'use client' is required on every component that calls Liveblocks hooks in App Router — server components cannot use hooks at all, and forgetting the directive produces an opaque “Hooks can only be called inside the body of a function component” error during SSR.

Fix 1: Set Up Liveblocks with React

npm install @liveblocks/client @liveblocks/react @liveblocks/node
// liveblocks.config.ts
import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react';

const client = createClient({
  // Development: public key (client-side, no auth needed)
  publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!,

  // Production: use authEndpoint instead
  // authEndpoint: '/api/liveblocks-auth',
});

// Define types for your application
type Presence = {
  cursor: { x: number; y: number } | null;
  name: string;
  color: string;
};

type Storage = {
  document: LiveObject<{
    title: string;
    content: string;
    lastEditedBy: string;
  }>;
  tasks: LiveList<LiveObject<{
    id: string;
    text: string;
    completed: boolean;
  }>>;
};

type UserMeta = {
  id: string;
  info: {
    name: string;
    avatar: string;
    color: string;
  };
};

export const {
  RoomProvider,
  useOthers,
  useMyPresence,
  useSelf,
  useStorage,
  useMutation,
  useHistory,
  useUndo,
  useRedo,
  useStatus,
  suspense: {
    RoomProvider: SuspenseRoomProvider,
    useOthers: useSuspenseOthers,
    useStorage: useSuspenseStorage,
  },
} = createRoomContext<Presence, Storage, UserMeta>(client);
// app/room/[id]/page.tsx
'use client';

import { RoomProvider } from '@/liveblocks.config';
import { LiveObject, LiveList } from '@liveblocks/client';
import { CollaborativeEditor } from '@/components/CollaborativeEditor';

export default function RoomPage({ params }: { params: { id: string } }) {
  return (
    <RoomProvider
      id={`room-${params.id}`}
      initialPresence={{ cursor: null, name: 'Anonymous', color: '#000' }}
      initialStorage={{
        document: new LiveObject({
          title: 'Untitled',
          content: '',
          lastEditedBy: '',
        }),
        tasks: new LiveList([]),
      }}
    >
      <CollaborativeEditor />
    </RoomProvider>
  );
}

Fix 2: Real-Time Presence (Cursors, Selections)

// components/CollaborativeEditor.tsx
'use client';

import { useMyPresence, useOthers, useSelf } from '@/liveblocks.config';
import { useEffect } from 'react';

export function CollaborativeEditor() {
  const [myPresence, updateMyPresence] = useMyPresence();
  const others = useOthers();
  const self = useSelf();

  // Track cursor position
  useEffect(() => {
    function handlePointerMove(e: PointerEvent) {
      updateMyPresence({
        cursor: { x: e.clientX, y: e.clientY },
      });
    }

    function handlePointerLeave() {
      updateMyPresence({ cursor: null });
    }

    window.addEventListener('pointermove', handlePointerMove);
    window.addEventListener('pointerleave', handlePointerLeave);

    return () => {
      window.removeEventListener('pointermove', handlePointerMove);
      window.removeEventListener('pointerleave', handlePointerLeave);
    };
  }, [updateMyPresence]);

  return (
    <div style={{ position: 'relative', width: '100%', height: '100vh' }}>
      {/* Show other users' cursors */}
      {others.map(({ connectionId, presence, info }) => {
        if (!presence.cursor) return null;

        return (
          <div
            key={connectionId}
            style={{
              position: 'absolute',
              left: presence.cursor.x,
              top: presence.cursor.y,
              pointerEvents: 'none',
              transform: 'translate(-4px, -4px)',
              zIndex: 50,
            }}
          >
            {/* Cursor dot */}
            <svg width="24" height="24" viewBox="0 0 24 24">
              <path
                d="M5 3l14 8-8 3-3 8z"
                fill={info?.color || presence.color}
              />
            </svg>
            {/* Name label */}
            <span style={{
              backgroundColor: info?.color || presence.color,
              color: 'white',
              padding: '2px 6px',
              borderRadius: '4px',
              fontSize: '12px',
              whiteSpace: 'nowrap',
            }}>
              {info?.name || presence.name}
            </span>
          </div>
        );
      })}

      {/* User list */}
      <div style={{ display: 'flex', gap: '4px', padding: '8px' }}>
        {self && (
          <div style={{
            width: '32px', height: '32px', borderRadius: '50%',
            backgroundColor: self.info?.color, border: '2px solid white',
          }} title={`${self.info?.name} (you)`} />
        )}
        {others.map(({ connectionId, info }) => (
          <div key={connectionId} style={{
            width: '32px', height: '32px', borderRadius: '50%',
            backgroundColor: info?.color, border: '2px solid white',
          }} title={info?.name} />
        ))}
      </div>

      {/* Editor content */}
      <EditorContent />
    </div>
  );
}

Fix 3: Conflict-Free Storage (CRDT)

'use client';

import { useStorage, useMutation } from '@/liveblocks.config';
import { LiveObject } from '@liveblocks/client';

function TaskList() {
  // Read from storage — reactive, updates in real-time
  const tasks = useStorage((root) => root.tasks);
  const title = useStorage((root) => root.document.title);

  // Mutations — write to storage using CRDT methods
  const addTask = useMutation(({ storage }, text: string) => {
    const tasks = storage.get('tasks');
    tasks.push(new LiveObject({
      id: crypto.randomUUID(),
      text,
      completed: false,
    }));
  }, []);

  const toggleTask = useMutation(({ storage }, taskId: string) => {
    const tasks = storage.get('tasks');
    const task = tasks.find(t => t.get('id') === taskId);
    if (task) {
      task.set('completed', !task.get('completed'));
    }
  }, []);

  const deleteTask = useMutation(({ storage }, taskId: string) => {
    const tasks = storage.get('tasks');
    const index = tasks.findIndex(t => t.get('id') === taskId);
    if (index !== -1) {
      tasks.delete(index);
    }
  }, []);

  const updateTitle = useMutation(({ storage }, newTitle: string) => {
    storage.get('document').set('title', newTitle);
  }, []);

  if (tasks === null) return <div>Loading...</div>;

  return (
    <div>
      <input
        value={title ?? ''}
        onChange={(e) => updateTitle(e.target.value)}
      />

      <ul>
        {tasks.map((task, index) => (
          <li key={task.id}>
            <input
              type="checkbox"
              checked={task.completed}
              onChange={() => toggleTask(task.id)}
            />
            <span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
              {task.text}
            </span>
            <button onClick={() => deleteTask(task.id)}>×</button>
          </li>
        ))}
      </ul>

      <button onClick={() => {
        const text = prompt('New task:');
        if (text) addTask(text);
      }}>
        Add Task
      </button>
    </div>
  );
}

Fix 4: Authentication

// app/api/liveblocks-auth/route.ts — Next.js App Router
import { Liveblocks } from '@liveblocks/node';
import { auth } from '@/auth';  // Your auth solution

const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  // Optionally check room permissions
  const { room } = await request.json();

  const lbSession = liveblocks.prepareSession(session.user.id, {
    userInfo: {
      name: session.user.name ?? 'Anonymous',
      avatar: session.user.image ?? '',
      color: generateColor(session.user.id),
    },
  });

  // Grant access to specific rooms
  lbSession.allow(room, lbSession.FULL_ACCESS);
  // Or read-only: lbSession.allow(room, lbSession.READ_ACCESS);

  const { status, body } = await lbSession.authorize();
  return new Response(body, { status });
}
// liveblocks.config.ts — switch to auth endpoint for production
const client = createClient({
  authEndpoint: '/api/liveblocks-auth',
  // Remove publicApiKey in production
});

Fix 5: Undo/Redo and History

'use client';

import { useHistory, useUndo, useRedo, useMutation } from '@/liveblocks.config';

function EditorToolbar() {
  const history = useHistory();
  const undo = useUndo();
  const redo = useRedo();

  // Batch multiple mutations into one undo step
  const moveAndRename = useMutation(({ storage }) => {
    storage.get('document').set('title', 'Moved Document');
    storage.get('document').set('content', 'Updated after move');
    // Both changes are one undo step because they're in one mutation
  }, []);

  // Pause/resume history for drag operations
  const startDrag = () => history.pause();
  const endDrag = () => history.resume();

  return (
    <div>
      <button onClick={undo} disabled={!history.canUndo()}>Undo</button>
      <button onClick={redo} disabled={!history.canRedo()}>Redo</button>
    </div>
  );
}

Fix 6: Connection Status and Error Handling

'use client';

import { useStatus } from '@/liveblocks.config';

function ConnectionIndicator() {
  const status = useStatus();

  // status: 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected'

  const colors = {
    initial: '#gray',
    connecting: '#yellow',
    connected: '#green',
    reconnecting: '#orange',
    disconnected: '#red',
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
      <div style={{
        width: '8px',
        height: '8px',
        borderRadius: '50%',
        backgroundColor: colors[status],
      }} />
      <span>
        {status === 'connected' ? 'Connected' :
         status === 'reconnecting' ? 'Reconnecting...' :
         status === 'connecting' ? 'Connecting...' :
         'Disconnected'}
      </span>
    </div>
  );
}

Still Not Working?

useOthers() always returns empty array — you need at least two clients connected to the same room. Open two browser tabs with the same room ID. Also verify the RoomProvider has a valid id prop and that initialPresence is set. Without initialPresence, the connection may not broadcast presence data.

useStorage returns null forever — storage must be initialized. Pass initialStorage to RoomProvider. If the room already has data from a previous session, the initial storage is ignored and existing data is loaded. If you’re using Suspense mode (SuspenseRoomProvider), wrap the component in a <Suspense> boundary.

Mutations run locally but don’t sync to others — make sure mutations use the CRDT API (storage.get('key').set(...)) and not plain JavaScript assignment. Also check that both clients are in the same room (same room ID). The WebSocket connection must be established — check useStatus() returns 'connected'.

Authentication endpoint returns 403 — verify LIVEBLOCKS_SECRET_KEY is set (not the public key). The secret key starts with sk_. Also check that lbSession.allow(room, ...) is called with the correct room ID matching what the client requests.

WebSocket connects then immediately disconnects with code 4001 — the auth token expired or the room ID does not match the one the token was issued for. Liveblocks tokens are scoped to specific rooms, so changing the id prop on RoomProvider after mount invalidates the token. Always pass a stable room ID, and if the user switches rooms, unmount and remount the provider rather than mutating the prop. Log the room ID in both the client and the prepareSession call to confirm they match byte-for-byte.

Yjs binding loses cursor state on tab switch — when using @liveblocks/yjs, the Yjs awareness state is separate from Liveblocks presence. On mobile Safari, backgrounding the tab pauses the WebSocket and the Yjs awareness state clears. Use Liveblocks useMyPresence for cursor data instead of Yjs awareness when targeting mobile WebViews, since presence reconnects automatically.

Storage mutations succeed locally but fail to sync with “transaction conflict” — this happens when two clients simultaneously mutate the same LiveList index. The Liveblocks CRDT resolves most conflicts automatically, but indexed delete(index) operations conflict when both clients delete the same index in the same tick. Use delete by ID lookup instead of by stable index, or wrap simultaneous edits in useHistory().pause() blocks.

For related real-time and React issues, see Fix: React useState Not Updating, Fix: Convex Not Working, Fix: Pusher Not Working, and Fix: Supabase Realtime Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles