DEV Community

Cover image for Keeping Users in Sync: Building Real-time Collaboration with Convex
Hamza Saleem for Convex Champions

Posted on • Originally published at stack.convex.dev

Keeping Users in Sync: Building Real-time Collaboration with Convex

Sticky my startup, is inspired by the idea of making collaboration as natural as doing it in person. Building features for Stick was both challenging and exciting experience. As I worked through it, I gained alot of insights on how to keep users in sync, handle data efficiently and to make sure everything works smoothly.

Board

Why Real Time Collaboration?

Realtime collaboration is important for modern workflow. Either for brainstorming some ideas, editing documents or debugging your code. Immediate feedback keeps the momentum alive. It lets you bounce ideas of off each other just like you will do in person.

Apps like Google Docs or Figma have done a great job making this possible. I tried to bring the same effortless feel to brainstorming ideas using sticky notes,

Challenges making Real Time Collaboration work:

Its not just about syncing data but also about creating a seamless and engaging experience. Here's how i approached some of the problems using Convex:

Sync and Optimistic Updates

Syncing data in real time is a common challenge in a collaborative app. In Sticky, each user's change needs to be reflected across all the active sessions instantly the traditional approach would require creating a complex system using Pollling and WebSockets. Which requires ton of custom logic and backend setup.

How Convex Solves This:

Convex simplifies this with a native real-time data sync mechanism. By using the useQuery hook, you can easily fetch and sync data, like sticky notes, with minimal setup. Convex handles the underlying complexities of WebSocket connections, keeping all clients in sync automatically. This lets developers focus on creating features instead of managing the socket connections to keep everything connected in real time

Optimistic Updates:

When collaborating in real time, you'd expect changes to appear instantly, however network latency can cause delays in reflecting these updates making the user experience sluggish. to address this properly. I used optimistic updates to instantly reflect changes in the UI even before server save changes in the backend.

What Optimistic Updates Help Solve:

Optimistic updates solve the problem of UI delay caused by network latency. When user creates a new note the change is immediately shown in the Board even though the mutation maybe still be pending. The change is rolled back if mutation fails ensuring data consistency without effecting the user experience.

const createNote = useMutation(api.notes.createNote).withOptimisticUpdate(
  (localStore, args) => {
    const existingNotes =
      localStore.getQuery(api.notes.getNotes, { boardId: actualBoardId }) || [];
    const tempId = `temp_${Date.now()}` as Id<"notes">;
    const now = Date.now();
    localStore.setQuery(api.notes.getNotes, { boardId: actualBoardId }, [
      ...existingNotes,
      { _id: tempId, _creationTime: now, ...args },
    ]);
  }
);
Enter fullscreen mode Exit fullscreen mode

By immediately showing the new note on the board, users can continue collaborating without waiting for the server's response. Only if the mutation completes with a different result will the UI be updated accordingly.

Real-time Presence Tracking Tracking who is online and where they are on the board is essential for real-time collaboration. In Sticky, I needed to show users cursor positions, indicating where others are working on the board. Real-time presence tracking also ensures that the team knows who is active and engaged.

What I Learned:

Implementing real-time presence required handling frequent updates of user cursor positions, which can overwhelm the system if not managed efficiently.

How Convex Solves This:

I used debounced updates to efficiently manage the frequency of presence updates. This function ensures the database isn't overloaded with too many requests by limiting the frequent updates.

Hereโ€™s a quick explanation of debouncing:

debouncing is a technique where the function only triggers after a specified delay, avoiding redundant or unnecessary calls. In this case, it helps prevent sending excessive updates for small, rapid movements of the cursor.

export function usePresence(boardId: Id<"boards">, isShared: boolean) {
  const updatePresence = useMutation(api.presence.updatePresence);
  const removePresence = useMutation(api.presence.removePresence);
  const activeUsers = useQuery(api.presence.getActiveUsers, { boardId });
  const cursorPositionRef = useRef({ x: 0, y: 0 });
  const [localCursorPosition, setLocalCursorPosition] = useState({ x: 0, y: 0 });

  const debouncedUpdatePresence = useCallback(
    debounce((position: { x: number; y: number }) => {
      if (isShared) {
        updatePresence({
          boardId,
          cursorPosition: position,
          isHeartbeat: false
        });
      }
    }, PRESENCE_UPDATE_INTERVAL, { maxWait: PRESENCE_UPDATE_INTERVAL * 2 }),
    [boardId, updatePresence, isShared]
  );

  useEffect(() => {
    if (!isShared) return;

    const heartbeatInterval = setInterval(() => {
      updatePresence({
        boardId,
        cursorPosition: cursorPositionRef.current,
        isHeartbeat: true
      });
    }, HEARTBEAT_INTERVAL);

    return () => {
      clearInterval(heartbeatInterval);
      removePresence({ boardId });
    };
  }, [boardId, updatePresence, removePresence, isShared]);

  return {
    activeUsers: isShared ? activeUsers : [],
    updateCursorPosition,
    localCursorPosition
  };
}
Enter fullscreen mode Exit fullscreen mode

By debouncing the updates, the system can efficiently tracks user activity without overwhelming the backend or causing performance issues.

Schema Design and Indexing As real-time data grows, efficient database design becomes crucial. I had to ensure that data could be retrieved quickly as the board and user base expanded.

How Convex Helps:

Convex's schema design allows for easy definition of tables and indexes.

For example, the presence table schema enables efficient queries based on user and board, as well as tracking the last update time for each user.

presence: defineTable({
  userId: v.id("users"),
  boardId: v.id("boards"),
  lastUpdated: v.number(),
  cursorPosition: v.object({
    x: v.number(),
    y: v.number(),
  }),
})
  .index("by_board", ["boardId"])
  .index("by_user_and_board", ["userId", "boardId"])
  .index("by_board_and_lastUpdated", ["boardId", "lastUpdated"]);
Enter fullscreen mode Exit fullscreen mode

These indexes ensure that as the database grows, data retrieval remains fast, even with a large number of active users or boards.

End-to-End Type Safety A major advantage of using Convex is its seamless integration with TypeScript. The type safety that TypeScript offers across the stack is a huge benefit when building real-time systems. With type-safe queries, mutations, and schema definitions, I was able to catch potential bugs during development, making the entire process smoother and more predictable.

Lesson Learned

I learned was how important is is for state management to be simple. Real time systems are naturally complex but with right tools it can be make a huge difference. With Convex, I can focus on creating a great user experience instead of worrying about the technicals.

A Final Thought

Building Sticky was a rewarding experience. The process taught me a lot about real-time systems and the value of tools that simplify complex tasks. For anyone looking to implement similar features, my advice is to start small, leverage existing tools, and iterate as you learn.

Check out Sticky to see these features in action or explore the source code for more implementation details.

Top comments (0)