Fix: Liveblocks Not Working — Room Not Connecting, Presence Not Syncing, or Storage Mutations Lost
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 openOr 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 changeOr the connection drops with an authentication error:
Error: Liveblocks: Could not connect to room "my-room" — authentication failedOr useStorage returns null forever:
const document = useStorage((root) => root.document);
// null — never resolves, Suspense fallback shown indefinitelyWhy 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: cmdk Not Working — Command Palette Not Opening, Items Not Filtering, or Keyboard Navigation Broken
How to fix cmdk command palette issues — Dialog setup, custom filtering, groups and separators, keyboard shortcuts, async search, nested pages, and integration with shadcn/ui and Tailwind.
Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected
How to fix Conform form validation issues — useForm setup with Zod, server action integration, nested and array fields, file uploads, progressive enhancement, and Remix and Next.js usage.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Lingui Not Working — Messages Not Extracted, Translations Missing, or Macro Errors
How to fix Lingui.js i18n issues — setup with React, message extraction, macro compilation, ICU format, lazy loading catalogs, and Next.js integration.