Real-Time State Synchronization
The Fifth Category of State
Most state in your app is pull-based: you request data, the server responds. Real-time state is the opposite — it's push-based: the server (or other clients) send updates to you without you asking. This fundamentally changes how you manage state.
Real-time state shows up in: chat messages, collaborative editing, live cursors, notifications, stock tickers, multiplayer interactions, presence indicators ("5 users viewing this page"), and live dashboards.
The challenge isn't receiving the data — WebSockets and Server-Sent Events handle transport. The challenge is merging remote updates with local state without conflicts, race conditions, or data loss.
Real-time state is like a shared whiteboard in a room full of people writing simultaneously. Everyone can write at the same time, and everyone needs to see everyone else's changes instantly. The hard problems aren't about the whiteboard surface (that's the WebSocket) — they're about what happens when two people write in the same spot at the same time. Do you pick a winner? Merge both changes? Ask the users to resolve it? That conflict resolution strategy IS your real-time architecture.
Transport: WebSocket vs Server-Sent Events
Before diving into state synchronization, let's clarify the transport layer:
| Feature | WebSocket | Server-Sent Events (SSE) |
|---|---|---|
| Direction | Bidirectional (client and server send) | Server to client only |
| Protocol | ws:// or wss:// (separate protocol) | Regular HTTP (text/event-stream) |
| Reconnection | Manual — you handle reconnect logic | Automatic — browser reconnects natively |
| Binary data | Yes (ArrayBuffer, Blob) | No — text only (can send JSON) |
| HTTP/2 multiplexing | No — separate TCP connection | Yes — shares connection with other requests |
| Browser support | Universal | Universal (except IE, which is dead) |
| Best for | Chat, gaming, collaborative editing (bidirectional) | Notifications, live feeds, dashboards (server push) |
For most real-time features, the choice is straightforward: if the client needs to send messages to the server, use WebSocket. If the server just pushes updates, SSE is simpler and works with existing HTTP infrastructure.
WebSocket Integration with State Libraries
WebSocket + TanStack Query
For real-time data that's also fetched initially from an API, you can use WebSocket messages to update the TanStack Query cache:
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
function useRealtimeNotifications() {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/notifications');
ws.onmessage = (event) => {
const notification = JSON.parse(event.data);
queryClient.setQueryData<Notification[]>(
['notifications'],
(old) => old ? [notification, ...old] : [notification]
);
queryClient.invalidateQueries({ queryKey: ['notification-count'] });
};
ws.onclose = () => {
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
}, 1000);
};
return () => ws.close();
}, [queryClient]);
}
This hybrid approach uses TanStack Query for the initial fetch and cache management, and WebSocket messages for real-time updates. On disconnect, it falls back to a refetch to catch any missed messages.
WebSocket + Zustand
For pure client-side real-time state (like presence or cursors), Zustand is a natural fit:
import { create } from 'zustand';
interface Cursor {
userId: string;
name: string;
x: number;
y: number;
color: string;
}
interface PresenceStore {
cursors: Map<string, Cursor>;
onlineUsers: Set<string>;
updateCursor: (cursor: Cursor) => void;
removeCursor: (userId: string) => void;
setOnlineUsers: (users: string[]) => void;
}
const usePresenceStore = create<PresenceStore>((set) => ({
cursors: new Map(),
onlineUsers: new Set(),
updateCursor: (cursor) =>
set((state) => {
const next = new Map(state.cursors);
next.set(cursor.userId, cursor);
return { cursors: next };
}),
removeCursor: (userId) =>
set((state) => {
const next = new Map(state.cursors);
next.delete(userId);
return { cursors: next };
}),
setOnlineUsers: (users) =>
set({ onlineUsers: new Set(users) }),
}));
Server-Sent Events for Live Feeds
SSE is perfect for one-way server push — live dashboards, notification feeds, activity streams:
function useLiveMetrics() {
const queryClient = useQueryClient();
useEffect(() => {
const eventSource = new EventSource('/api/metrics/stream');
eventSource.onmessage = (event) => {
const metrics = JSON.parse(event.data);
queryClient.setQueryData(['metrics', 'live'], metrics);
};
eventSource.onerror = () => {
eventSource.close();
queryClient.invalidateQueries({ queryKey: ['metrics'] });
};
return () => eventSource.close();
}, [queryClient]);
}
SSE handles reconnection automatically — if the connection drops, the browser reconnects and the server can resume from where it left off using the Last-Event-ID header.
The Conflict Problem
Here's where real-time gets hard. Two users edit the same document simultaneously:
- User A sees: "Hello World"
- User B sees: "Hello World"
- User A changes it to: "Hello Beautiful World" (inserted "Beautiful")
- User B changes it to: "Hello World**!**" (added "!")
- What should the final result be?
The correct answer is "Hello Beautiful World!" — both changes should be preserved. But achieving this automatically, without user intervention, at scale, is one of the hardest problems in distributed systems.
Two main approaches exist:
Operational Transform (OT)
OT transforms operations relative to concurrent operations. If User A inserts "Beautiful " at position 6 and User B inserts "!" at position 11, OT transforms B's operation to account for A's insertion: B's insert position becomes 21 (11 + length of "Beautiful ").
OT requires a central server to order and transform operations. Google Docs uses OT. It works well but is complex to implement correctly — the number of operation types grows quadratically, and edge cases are numerous.
CRDTs (Conflict-Free Replicated Data Types)
CRDTs are data structures designed to be merged without conflicts. Instead of transforming operations, CRDTs structure data so that any two copies can always be merged deterministically — regardless of the order updates arrive.
The key property: CRDTs are commutative and idempotent. It doesn't matter what order you apply updates — the final state is always the same. This eliminates the need for a central server to order operations.
Yjs: Practical CRDTs
Yjs is the most popular CRDT library for JavaScript. It provides shared data types that sync automatically:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('wss://your-server.com', 'room-id', ydoc);
const ytext = ydoc.getText('editor');
const yarray = ydoc.getArray<string>('todos');
const ymap = ydoc.getMap('settings');
ytext.observe((event) => {
console.log('Text changed:', ytext.toString());
});
ytext.insert(0, 'Hello ');
yarray.push(['Buy groceries']);
ymap.set('theme', 'dark');
Yjs provides CRDT types for text (Y.Text), arrays (Y.Array), maps (Y.Map), and XML (Y.XmlFragment for rich text). These types sync across clients automatically through providers (WebSocket, WebRTC, or even IndexedDB for persistence).
Yjs + React Integration
import { useEffect, useState } from 'react';
import * as Y from 'yjs';
function useYArray<T>(yarray: Y.Array<T>): T[] {
const [items, setItems] = useState<T[]>(() => yarray.toArray());
useEffect(() => {
const observer = () => setItems(yarray.toArray());
yarray.observe(observer);
return () => yarray.unobserve(observer);
}, [yarray]);
return items;
}
function CollaborativeTodoList({ ydoc }: { ydoc: Y.Doc }) {
const todos = useYArray(ydoc.getArray<string>('todos'));
return (
<ul>
{todos.map((todo, i) => (
<li key={i}>{todo}</li>
))}
</ul>
);
}
Presence: Who's Here?
Presence tells users who else is active — online indicators, live cursors, "typing..." indicators. It's simpler than collaborative editing but has its own patterns:
interface PresenceData {
cursor: { x: number; y: number } | null;
selection: { start: number; end: number } | null;
name: string;
color: string;
}
class PresenceManager {
private ws: WebSocket;
private heartbeatInterval: ReturnType<typeof setInterval>;
constructor(roomId: string, userId: string) {
this.ws = new WebSocket(`wss://api.example.com/presence/${roomId}`);
this.heartbeatInterval = setInterval(() => {
this.ws.send(JSON.stringify({ type: 'heartbeat', userId }));
}, 5000);
}
updatePresence(data: Partial<PresenceData>) {
this.ws.send(JSON.stringify({ type: 'presence', ...data }));
}
destroy() {
clearInterval(this.heartbeatInterval);
this.ws.close();
}
}
Presence data is inherently ephemeral — when a user disconnects, their presence should be removed. Heartbeats detect stale connections (user closed the tab without a clean disconnect).
Cursor sharing performance
Sending cursor positions at 60fps over WebSocket is wasteful. Most real-time apps throttle cursor updates to 20-30fps — the human eye can't distinguish cursor movements faster than that. Additionally, use requestAnimationFrame on the receiving end to interpolate between received positions for smooth visual movement:
let targetX = 0, targetY = 0;
let currentX = 0, currentY = 0;
function animate() {
currentX += (targetX - currentX) * 0.15;
currentY += (targetY - currentY) * 0.15;
cursorElement.style.transform = `translate(${currentX}px, ${currentY}px)`;
requestAnimationFrame(animate);
}This lerp (linear interpolation) gives smooth cursor movement even with 20fps updates.
Scaling Considerations
Real-time systems have unique scaling challenges:
| Challenge | Problem | Solution |
|---|---|---|
| Connection limits | Each WebSocket is a persistent TCP connection. A single server handles ~10K-50K connections. | Use a pub-sub layer (Redis Pub/Sub, NATS) to fan out across multiple servers |
| Message fan-out | A room with 100 users means each message is sent 99 times | Use a message broker. Batch and compress messages. Rate-limit cursor updates. |
| State recovery | New users joining a room need the full current state | Use a CRDT document snapshot for initial sync, then apply incremental updates |
| Stale connections | Users close tabs without clean disconnect | Heartbeat mechanism with server-side timeout (no heartbeat for 30s = disconnected) |
| Ordering | Messages can arrive out of order across servers | CRDTs handle order-independence by design. For ordered data, use vector clocks or Lamport timestamps. |
| What developers do | What they should do |
|---|---|
| Storing real-time data (cursor positions, presence) in TanStack Query TanStack Query's caching, staleTime, and refetching semantics don't apply to ephemeral data that changes 30 times per second. Use a client state library with fast update paths. | Use Zustand or Jotai for ephemeral real-time state. TanStack Query is for server-cached data. |
| Sending every cursor movement as a separate WebSocket message at 60fps 60 messages per second per user doesn't scale. With 50 users, that's 3,000 messages/second for cursors alone. Throttle to 20fps (1,000 msg/s) and interpolate for visual smoothness. | Throttle cursor updates to 20-30fps and use interpolation on the receiving end |
| Implementing custom conflict resolution for collaborative text editing Conflict resolution in text editing has extremely subtle edge cases (concurrent inserts at the same position, deletions of ranges being edited, undo across users). Yjs has thousands of hours of testing. Your custom implementation won't. | Use an established CRDT library (Yjs, Automerge) that has been battle-tested |
- 1Use WebSocket for bidirectional communication (chat, collaboration). Use SSE for server push (notifications, live feeds).
- 2Real-time state is ephemeral — use Zustand or Jotai, not TanStack Query.
- 3CRDTs (Yjs, Automerge) solve collaborative editing without a central server. OT requires one.
- 4Throttle cursor updates to 20-30fps and interpolate on the receiving end for smooth visuals.
- 5Presence needs heartbeats to detect stale connections. No heartbeat for 30s = user disconnected.
- 6For scaling, use Redis Pub/Sub or NATS to fan out messages across multiple WebSocket servers.