Back to blog
EngineeringJun 5, 20266 min read

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:

  1. The client updates the local UI immediately using optimistic updates.
  2. An HTTP request is dispatched to our NestJS server to write to the PostgreSQL database.
  3. The server processes the mutation and broadcasts a lightweight synchronization signal via WebSockets to all clients in that workspace.
  4. 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.

socketSync.ts
// 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.

Newsletter

Subscribe to our engineering log

Technical articles on WebSockets, ProseMirror plugins, database optimizations, and design philosophy. Delivered twice a month.