Skip to main content

Real-Time Kanban: WebSocket Collaboration & Sync

A real-time collaborative kanban board lets multiple users move tasks simultaneously, see changes instantly, and resolve conflicts automatically. WebSockets establish persistent connections; operational transformation or CRDT algorithms merge concurrent edits. Production collaboration apps (Figma processes 10+ million edits per day) combine WebSockets, version vectors, and eventual consistency to scale across thousands of concurrent users.

Establishing WebSocket Connections

Set up a WebSocket connection and handle lifecycle events:

function useWebSocket(url) {
const [isConnected, setIsConnected] = React.useState(false);
const wsRef = React.useRef(null);
const reconnectTimeoutRef = React.useRef(null);

React.useEffect(() => {
function connect() {
try {
wsRef.current = new WebSocket(url);

wsRef.current.onopen = () => {
setIsConnected(true);
console.log('WebSocket connected');
};

wsRef.current.onclose = () => {
setIsConnected(false);
// Reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(connect, 3000);
};

wsRef.current.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (e) {
console.error('Failed to connect:', e);
}
}

connect();

return () => {
if (wsRef.current) {
wsRef.current.close();
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [url]);

function send(message) {
if (isConnected && wsRef.current) {
wsRef.current.send(JSON.stringify(message));
}
}

function subscribe(handler) {
if (!wsRef.current) return;

wsRef.current.onmessage = (event) => {
const data = JSON.parse(event.data);
handler(data);
};
}

return { isConnected, send, subscribe };
}

The hook manages connection lifecycle: retry on disconnect, send messages, and subscribe to incoming events. Automatic reconnection ensures resilience.

Broadcasting Task Updates

When a user moves a task, broadcast the change to all other clients:

function Board() {
const [tasks, setTasks] = React.useState(initialTasks);
const userId = React.useRef(generateUserId()).current;
const { isConnected, send, subscribe } = useWebSocket('ws://localhost:8080');

React.useEffect(() => {
// Subscribe to incoming updates from other users
subscribe((message) => {
if (message.type === 'TASK_MOVED') {
const { taskId, fromColumn, toColumn } = message.payload;
// Update local state with remote change
setTasks((prev) => {
const updated = { ...prev };
const source = [...prev[fromColumn]];
const target = [...prev[toColumn]];
const index = source.findIndex((t) => t.id === taskId);
const [task] = source.splice(index, 1);
target.push(task);
updated[fromColumn] = source;
updated[toColumn] = target;
return updated;
});
}
});
}, [subscribe]);

function moveTask(taskId, fromColumn, toColumn) {
// Optimistic update: update local state immediately
setTasks((prev) => {
const updated = { ...prev };
const source = [...prev[fromColumn]];
const target = [...prev[toColumn]];
const index = source.findIndex((t) => t.id === taskId);
const [task] = source.splice(index, 1);
target.push(task);
updated[fromColumn] = source;
updated[toColumn] = target;
return updated;
});

// Broadcast to all clients
send({
type: 'TASK_MOVED',
payload: { taskId, fromColumn, toColumn },
userId, // Identify which user made the change
timestamp: Date.now(),
});
}

return (
<div className="board">
{!isConnected && <div className="warning">Offline</div>}
{/* ... render board ... */}
</div>
);
}

The board broadcasts moves to the server, which re-broadcasts to all clients. Each client updates optimistically, then applies remote updates. If your local move conflicts with a remote move, eventual consistency algorithms resolve it.

Tracking User Presence

Show which users are online and what they're viewing:

function usePresence() {
const userId = React.useRef(generateUserId()).current;
const { send, subscribe } = useWebSocket('ws://localhost:8080');
const [presence, setPresence] = React.useState({});
const [viewingColumn, setViewingColumn] = React.useState('todo');

React.useEffect(() => {
// Broadcast presence every 5 seconds
const interval = setInterval(() => {
send({
type: 'PRESENCE_UPDATE',
userId,
viewingColumn,
timestamp: Date.now(),
});
}, 5000);

// Subscribe to presence updates
subscribe((message) => {
if (message.type === 'PRESENCE_UPDATE') {
setPresence((prev) => ({
...prev,
[message.userId]: {
viewingColumn: message.viewingColumn,
lastSeen: message.timestamp,
},
}));
}
});

return () => clearInterval(interval);
}, [send, subscribe, viewingColumn, userId]);

return { presence, setViewingColumn };
}

function Board() {
const { presence, setViewingColumn } = usePresence();

return (
<div className="board">
<div className="presence-bar">
{Object.entries(presence).map(([userId, data]) => (
<span key={userId} className="user-badge" title={userId}>
{data.viewingColumn}
</span>
))}
</div>
{/* ... board content ... */}
</div>
);
}

The presence feature broadcasts which column each user is viewing every 5 seconds. Other clients render presence indicators, helping teams coordinate without verbal communication.

Conflict Resolution with Version Vectors

When two users move tasks simultaneously, conflicts arise. Use version vectors (lamport clocks) to detect and resolve them:

function useOperationalTransform() {
const [version, setVersion] = React.useState(0);
const [tasks, setTasks] = React.useState(initialTasks);
const pendingOpsRef = React.useRef([]);

function applyOp(op) {
// Check version to detect conflicts
if (op.baseVersion !== version) {
// Conflict: remote op is based on an older state
// Rebase and transform the operation
const transformedOp = transformOp(op, pendingOpsRef.current);
pendingOpsRef.current.push(transformedOp);
} else {
// No conflict: apply directly
pendingOpsRef.current.push(op);
}

// Apply all pending ops in order
setTasks((prev) => {
let updated = prev;
for (const pendingOp of pendingOpsRef.current) {
updated = applyTaskOp(updated, pendingOp);
}
pendingOpsRef.current = [];
setVersion((v) => v + 1);
return updated;
});
}

function transformOp(op1, ops2) {
// Simple transform: if both move the same task, apply op1 first
// More complex transform logic for actual CRDTs
return op1;
}

function applyTaskOp(state, op) {
const { type, taskId, fromColumn, toColumn } = op;
if (type !== 'MOVE_TASK') return state;

const updated = { ...state };
const source = [...state[fromColumn]];
const target = [...state[toColumn]];
const index = source.findIndex((t) => t.id === taskId);
const [task] = source.splice(index, 1);
target.push(task);
updated[fromColumn] = source;
updated[toColumn] = target;
return updated;
}

return { version, tasks, applyOp };
}

function Board() {
const { version, tasks, applyOp } = useOperationalTransform();
const { send, subscribe } = useWebSocket('ws://localhost:8080');

React.useEffect(() => {
subscribe((message) => {
if (message.type === 'TASK_MOVED') {
applyOp({
type: 'MOVE_TASK',
taskId: message.payload.taskId,
fromColumn: message.payload.fromColumn,
toColumn: message.payload.toColumn,
baseVersion: message.version,
});
}
});
}, [subscribe, applyOp]);

function moveTask(taskId, fromColumn, toColumn) {
// Local update
applyOp({
type: 'MOVE_TASK',
taskId,
fromColumn,
toColumn,
baseVersion: version,
});

// Broadcast to server
send({
type: 'TASK_MOVED',
payload: { taskId, fromColumn, toColumn },
version,
timestamp: Date.now(),
});
}

return <div className="board">{/* ... */}</div>;
}

Version vectors track which version each operation is based on. If a remote operation is based on an older state, the client transforms it before applying. This ensures eventual consistency: all clients converge to the same state regardless of operation order.

Using CRDT for Automatic Conflict Resolution

Alternatively, use a CRDT (Conflict-free Replicated Data Type) library like Yjs for automatic conflict resolution:

npm install yjs y-websocket
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

function Board() {
const ydocRef = React.useRef(new Y.Doc());
const tasksRef = React.useRef(ydocRef.current.getMap('tasks'));
const [tasks, setTasks] = React.useState({});

React.useEffect(() => {
const ydoc = ydocRef.current;
const tasksMap = tasksRef.current;

// Connect to WebSocket provider
const provider = new WebsocketProvider(
'ws://localhost:1234',
'kanban',
ydoc
);

// Subscribe to changes
tasksMap.observe((event) => {
const updated = tasksMap.toJSON();
setTasks(updated);
});

return () => {
provider.disconnect();
};
}, []);

function moveTask(taskId, fromColumn, toColumn) {
const tasksMap = tasksRef.current;
const tasksArray = tasksMap.get(fromColumn);

const task = tasksArray.find((t) => t.id === taskId);
tasksArray.delete(task);

const targetArray = tasksMap.get(toColumn);
targetArray.push([task]);
}

return <div className="board">{/* ... */}</div>;
}

Yjs handles conflict resolution automatically using CRDT semantics. All clients converge without explicit merge logic, making it ideal for real-time collaboration.

Key Takeaways

  • WebSocket connections: Establish persistent, auto-reconnecting channels for real-time updates.
  • Optimistic updates: Update local state immediately; remote updates merge with eventual consistency.
  • Presence tracking: Broadcast user location and status every 5-10 seconds.
  • Version vectors: Track operation versions to detect and resolve conflicts.
  • CRDTs: Use libraries like Yjs for automatic conflict-free synchronization.

Frequently Asked Questions

What if two users move the same card to different columns?

The server's merge logic decides: first-write-wins, last-write-wins, or user-defined rules. With CRDTs, both moves are applied in a conflict-free order, converging all clients to the same final state.

How do I prevent race conditions with WebSockets?

Assign each operation a unique ID and version number. Idempotency ensures applying the same operation twice is safe. Server-side deduplication discards duplicate operations.

What's the latency for real-time updates?

WebSocket latency is typically 50-200 ms depending on network. Optimistic updates (instant local change) mask this delay; by the time the server responds, the UI is already updated.

How do I handle dropped WebSocket connections?

Reconnect automatically (with exponential backoff). Queue operations while offline; sync when reconnected. CRDTs make this seamless since all clients eventually converge.

Can I use WebSockets for a large number of concurrent users?

Yes, but you need a scalable server (Node.js with ws or Python asyncio). Consider a managed service like Socket.io, Firebase Realtime Database, or Supabase Realtime for easier scaling.

Further Reading