How We Built the Real-Time Sync Layer for Team App Building

typescript dev.to

TL;DR

  • We run a dedicated Socket.IO service next to our Next.js app instead of using Supabase Realtime, because presence and "a teammate's generation just finished" aren't database rows.
  • Auth is a two-stage handshake: the socket server asks the Next.js API who the user is, so identity has one source of truth.
  • Every payload carries a tabId. Clients drop their own echoes with one line: if (tabId === this.getTabId()) return.
  • The sync channel carries pointers, not truth. When an event can't describe what changed, the client refetches from the database.
  • Presence is deduped by userId, not by socket, so a power user with five tabs is one avatar.
  • No live cursors, no CRDT. Both are deliberate omissions, explained at the bottom.

The problem

Two teammates open the same project. One prompts the AI to add a screen. Files stream into the virtual file system. The preview iframe hot-reloads. Chat messages flip from streaming to complete. If the second teammate's editor doesn't reflect any of that within a few hundred milliseconds, the product feels broken. Worse, they silently drift out of sync until one overwrites the other.

Here's the part most "how to build collab" posts miss. A Google Docs-style architecture assumes both users are typing keystrokes. In our editor most edits don't come from a human, they come from an AI generation that writes a dozen files over 90 seconds while multiple teammates watch. The AI's side of the conversation is a first-class multi-user event.

That reframing changes every downstream decision.

Why not Supabase Realtime

Our data plane is Supabase. Projects, files, messages, teams, and share permissions live in Postgres with row-level security, and Realtime can stream postgres_changes from any table. It was the obvious first thing to try.

We didn't use it, for one reason above all the others: presence isn't a row. Neither is "another user's generation just entered a terminal status, please refetch your thread." Encoding those as Postgres rows just so a Realtime channel could carry them would have been architectural laundering. The events would arrive, but every consumer would have to reconstruct semantics that were only lossily written in the first place.

So we run rapidnative-sync-server: a small stateful Node service that talks Socket.IO to the browser and REST to the Next.js app.

The two-stage auth handshake

Every WebSocket system has to answer one question the moment a socket connects: who is this. We answer it by making the sync server ask the app.

Browser  --join-room-->  Sync Server  --/api/sync/validate-->  Next.js API
         <--presence:update--          <--{userId,email,name,isAdmin}--
Enter fullscreen mode Exit fullscreen mode

Server side:

socket.on('join-room', async ({ projectId, tabId, authToken }) => {
  const res = await fetch(`${APP_URL}/api/sync/validate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ projectId, authToken }),
  });

  if (!res.ok) {
    socket.emit('join-error', { projectId });
    return;
  }

  const { userId, email, name, isAdmin } = await res.json();

  socket.data = { userId, email, name, isAdmin, tabId, projectId };
  socket.join(projectId);

  io.to(projectId).emit('presence:update', { users: usersInRoom(projectId) });
});
Enter fullscreen mode Exit fullscreen mode

Why route auth this way instead of terminating it in the sync server? Because identity in our system is a Next.js concern. It involves NextAuth sessions, our users table, and platform-admin checks driven by ADMIN_EMAILS. Duplicating any of that into the sync server creates two sources of truth for who counts as an admin, which is the kind of divergence that surfaces months later in exactly the wrong customer's account.

ProjectAuthService.validateProjectAccess is the single decision-point. It reads team membership, project_share rows, and the is_public flag, all under RLS, and returns a shape the sync server trusts without re-checking.

The event schema

Small and domain-shaped:

type SyncEvent =
  | 'files:created'   | 'files:updated'   | 'files:deleted'
  | 'messages:created'| 'messages:updated'| 'messages:deleted'
  | 'presence:update'
  | 'join-room'       | 'leave-room'
  | 'project:emit';   // generic passthrough, not yet formalized
Enter fullscreen mode Exit fullscreen mode

The interesting engineering isn't the events. It's the envelope.

The tabId trick

Every event carries a tabId, generated once per browser tab and stored in sessionStorage:

private getTabId(): string {
  let id = sessionStorage.getItem('rn_tab_id');
  if (!id) {
    id = crypto.randomUUID();
    sessionStorage.setItem('rn_tab_id', id);
  }
  return id;
}
Enter fullscreen mode Exit fullscreen mode

Every handler opens with the same guard:

private handleFileUpdated(payload: FileEvent) {
  if (payload.tabId === this.getTabId()) {
    return; // our own echo
  }
  this.dispatch(applyRemoteFileUpdate(payload.file));
}
Enter fullscreen mode Exit fullscreen mode

That single line prevents an entire class of bugs. When you save a file your own client emits files:updated. The server broadcasts it to everyone in the room, including you. Without the check your tab re-applies the update to its own Redux store, which sometimes clobbers unsaved local state, sometimes fires a redundant thunk, and always makes the state graph harder to reason about.

Broadcasting to the sender and letting the sender filter is deliberate. The alternative looks simpler:

// The version that quietly breaks
socket.broadcast.to(room).emit('files:updated', payload);
Enter fullscreen mode Exit fullscreen mode

Until one user has two tabs open. The tab you didn't type in still needs the update, and socket.broadcast skips the whole socket, not the whole tab. Per-tab dedup survives the "one power user with five tabs" case.

The same envelope caught a real bug. Two code paths emit messages:updated: feedback ratings send { messageId, feedback }, and generation status transitions send { id, status }. The original handler only destructured messageId, so every status update from another collaborator's generation was silently dropped:

// before
const { messageId, tabId } = payload;

// after
const targetId = payload.messageId ?? payload.id;
Enter fullscreen mode Exit fullscreen mode

Every payload shape has a comment above it now.

AI generation as a multi-user event

Generation isn't a keystroke. It's a stream that produces many messages and many files over tens of seconds, terminating asynchronously with a status transition rather than a "done" character. Two patterns make it bearable.

Silent refetch on terminal status. When messages:updated carries a status that isn't streaming, some other collaborator's generation just finished:

private handleMessageUpdated(payload: MessageEvent) {
  if (payload.tabId === this.getTabId()) return;

  const isTerminal = payload.status && payload.status !== 'streaming';
  if (!isTerminal) return;

  // our own stream owns this message's state, don't race it
  if (this.getState().editor.isAiRequestInProgress) return;

  this.dispatch(fetchMessages({ silent: true }));
}
Enter fullscreen mode Exit fullscreen mode

silent is doing real work. It refreshes the thread from the API without flipping the editor into the full-screen loading state. React reconciles by message id and the mounted thread updates in place. Blanking the surface would make every completed generation feel like a page reload for every viewer.

Compact-message full refresh. Long threads get compacted server-side into a summary message to keep context windows manageable. When that arrives, don't splice:

const hasCompact = newMessages.some((m) => m.type === 'compact');
if (hasCompact) {
  this.dispatch(fetchMessages({ silent: true }));
  return;
}
Enter fullscreen mode Exit fullscreen mode

A compact message means the earlier messages are semantically replaced. Any client-side patch would produce something that looks like a valid thread while hiding the fact that the thread was rewritten.

Both patterns follow one rule: the sync channel carries pointers, not truth. The database is truth. When the pointer says "something changed I can't fully describe," the client goes back to Postgres. Slower per event, but the real-time layer never has to encode every server-side transformation into its wire format.

Presence, deduped by userId

The avatars in the editor header are the most visible part of the layer, and the one place the multi-tab problem can't hide.

private handlePresence(users: PresenceUser[]) {
  const seen = new Set<string>();
  const deduped = [];

  for (const user of users) {
    if (seen.has(user.userId)) continue;
    seen.add(user.userId);
    deduped.push({
      userId: user.userId,
      email: user.email,
      name: user.name,
      isAdmin: user.isAdmin,
    });
  }

  this.dispatch(setActiveUsers(deduped));
}
Enter fullscreen mode Exit fullscreen mode

Without dedup a teammate with three tabs shows up as three avatars, and closing one tab makes one of "them" vanish. That's exactly the thing that erodes trust in a UI whose whole job is signalling presence.

The admin flag is filtered client-side, so regular users don't see platform admins in the avatar group. Support engineers open customer projects daily and those visits shouldn't materialize as ghost avatars on a customer's screen. Admins still see other admins, so support pairing works.

The team layer underneath

Everything rides on a team model the sync server doesn't need to know about, because Postgres enforces it. team_users maps users to teams with a role of owner, admin, or member. Owners can't be demoted, admins can't change each other's roles. project_share and project_share_team_user grant view or edit to specific teammates. is_public opens read-only access to anonymous visitors.

The sync server never queries the database directly. It asks the Next.js API, which runs every read through the requesting user's Supabase client. If a user shouldn't open a project, validateProjectAccess says no, the server refuses the room join, and no files:* event ever reaches their browser. The security boundary is at Postgres, not at Socket.IO, which is where you want it.

This is the architecture behind team projects in RapidNative, if you want to poke at the running version: open one project in two tabs and watch the avatars settle.

What we deliberately didn't build

No live cursors. A cursor is 30 to 60 events per second per user and needs a conflict-free rendering model to be worth anything. It would triple the sync server's load without solving a problem customers actually report. Comments carry spatial metadata (layerPath, coordinates) instead, which covers most of what teams want: leave a note on this specific button.

No CRDT. Yjs and Automerge are excellent and neither is in this stack. Our editing model is serial per file: one AI generation at a time, one human edit at a time, with the tabId envelope preventing cross-tab conflicts for the same user. Two teammates typing into the same file will fight, and we tell them so. A CRDT stops the fight at the cost of a new failure surface: merge outcomes users can't predict. For an app builder where the AI is the primary author, that trade doesn't pay off yet.

Wrapping up

Nothing here is exotic. Socket.IO in a Node process, an authenticated handshake through the Next.js API, domain-shaped events with a tabId envelope, a Redux slice that treats events as pointers back to the database, and RLS holding it together.

The pattern that carried the most weight is the smallest one: tabId in every payload, if (tabId === this.getTabId()) return in every handler. Three lines that killed an entire class of state bugs and made the rest of the system safe to broadcast into.

If you've built real-time collab: did you go server-side skip or client-side filter, and what broke? Drop a comment.

Source: dev.to

arrow_back Back to Tutorials