Real-Time Without the Mess
Rahul Bhatt
Co-founder, TasksFlow
Modern collaboration requires immediate feedback. If two project managers are looking at the same board, a status change should sync without a page refresh. However, building real-time synchronization can easily lead to a spaghetti of WebSocket handlers and cache invalidation bugs. Here is how we built our sync engine.
The Architecture
Instead of building a complex peer-to-peer syncing network, we settled on a hybrid approach using WebSockets for instant event propagation and standard HTTP endpoints for state mutation. When a user changes a task status:
- The client updates the local UI immediately using optimistic updates.
- An HTTP request is dispatched to our NestJS server to write to the PostgreSQL database.
- The server processes the mutation and broadcasts a lightweight synchronization signal via WebSockets to all clients in that workspace.
- Receiving clients fetch the delta or merge the event payload into their local React Query cache.
"By separation of concerns, our WebSockets don't carry database state—they carry action signals. This keeps the packet payloads small and makes debugging simple."
Handling Network Drops Safely
WebSockets are notoriously unstable on mobile networks. If a client goes offline, shifts columns, and comes back online, we run into state conflicts. We solved this with a sequence ID mismatch protocol. The client tracks the last sequence event number received. Upon reconnection, it requests all missing event payloads since its last known sequence number.
// WebSocket Reconnection Handler
socket.on("reconnect", async () => {
const missedEvents = await fetchMissedEvents(lastSequenceId);
if (missedEvents.status === "REFRESH_REQUIRED") {
queryClient.invalidateQueries(["workspace", workspaceId]);
} else {
applyDeltaEvents(missedEvents.data);
}
});
Optimistic UI Redux
Optimistic updates are crucial. Waiting 300ms for a network roundtrip before updating a card's position makes the app feel sluggish. By rendering the card in the target column instantly, the interaction feels snappy. If the server request fails (due to a permission error or database constraint), we roll back the cache to the previous state and show a toast warning. This gives users the best of both worlds: instant feedback and solid consistency.
Subscribe to our engineering log
Technical articles on WebSockets, ProseMirror plugins, database optimizations, and design philosophy. Delivered twice a month.