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​