All blueprints
Realtime

Realtime Collaboration App. Live everything.

A pragmatic pattern for multi-user apps where edits, presence, comments, and notifications need to be live across every connected client. Postgres replication for data, presence channels for who-is-here, and an optional CRDT layer for free-form text editing. No DIY WebSocket fleet.

Components

Supabase Postgres
Source of truth. Every change starts and ends here.
Supabase Realtime channels
WebSocket fan-out for postgres_changes events. Subscribers get inserts/updates/deletes live.
Presence channels
Who is currently viewing or editing this resource. Lightweight, ephemeral.
Broadcast channels
Custom events not backed by data — typing indicators, cursor positions, ephemeral signals.
Y.js (optional)
CRDT for collaborative free-form text. Only when truly needed; usually overkill.
Optimistic UI
Local update first, server confirms or reverts. Realtime keeps it consistent.
Conflict resolution
Last-write-wins for most fields, CRDT for genuinely concurrent text editing.

When to use this

  • Multiple users edit or view the same resource at the same time
  • You want changes to propagate without manual refresh
  • Presence (who is online, who is in this document) adds real value
  • Your data model is row-based, not document-based, for most fields

When not to use this

  • ×Single-user app with no collaboration — realtime is wasted complexity
  • ×Genuinely concurrent text-editing-as-a-service — you may need a dedicated collaboration backend like Liveblocks or Tiptap collaboration
  • ×Tens of thousands of concurrent users on a single channel — Supabase Realtime will struggle; reach for a dedicated tier or service
  • ×Strict ordering across distributed clients matters more than freshness — that is a CRDT or operational-transform problem, not a fan-out problem

The data flow

  • User makes a change. UI applies it optimistically.
  • Change is sent to the server (server action or API route) and committed to Postgres.
  • Postgres replication broadcasts the change via Realtime.
  • Every subscribed client (including the originator) receives the canonical event.
  • Originator’s optimistic state is reconciled against the canonical event. Other clients update their state.
  • On error, the originator reverts the optimistic update and surfaces a toast.

Subscribing to changes

// hooks/useRealtimeRows.ts
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'

export function useRealtimeRows<T>(
  table: string,
  filter: string,
  initial: T[]
) {
  const [rows, setRows] = useState(initial)

  useEffect(() => {
    const channel = supabase
      .channel(`${table}:${filter}`)
      .on('postgres_changes',
        { event: '*', schema: 'public', table, filter },
        (payload) => {
          setRows((prev) => applyChange(prev, payload))
        })
      .subscribe()
    return () => { channel.unsubscribe() }
  }, [table, filter])

  return rows
}

Presence

Presence is a separate channel that does not touch Postgres. Each connected client tracks itself with a small object — user_id, name, avatar — and the channel emits events when others join or leave.

const channel = supabase.channel(`presence:doc:${docId}`, {
  config: { presence: { key: userId } },
})

channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    setOnline(Object.values(state).flat())
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await channel.track({ userId, name, joinedAt: Date.now() })
    }
  })

Optimistic UI without tears

The hardest part of realtime is reconciling optimistic state with the authoritative state coming back over the channel. The trick is to give every optimistic action a temporary local ID, then match the canonical event by either the server-assigned ID or the unique fields the user just edited.

Keep optimistic state in a separate slot from canonical state. Render canonical when present, fall back to optimistic when not. Never mutate canonical state in place to add optimistic-only data; that is how you end up with rows that ghost in and out on reconnect.

CRDTs: only when you must

For most realtime apps, last-write-wins on individual fields is fine. Two users editing the same row at the same instant is rare; when it happens, the second write overwriting the first is a tolerable edge case.

For collaborative free-form text editing — many users in the same paragraph — you need CRDT or operational transform. Y.js works well, but introduces a meaningful complexity tax: a separate document model, sync layer, and persistence path. Only adopt when the user experience genuinely requires it.

Operational considerations

  • Throttle or debounce high-frequency events (cursor moves, typing) before they hit the channel
  • Consider broadcast (no DB write) for ephemeral signals; reserve postgres_changes for actual data
  • Reconnect handling: clients drop and reconnect; on reconnect, re-fetch canonical state, do not trust accumulated state
  • Rate-limit per-channel subscribers if user-driven channel names are possible
  • Monitor channel count and message rate — Supabase has limits, generous but real

Alternatives I considered

Liveblocks

Excellent at presence + cursors + CRDT-backed editors. More expensive at scale, opinionated abstractions, less flexible for non-document data.

Pusher / Ably

Strong messaging primitives. You still need a separate database. Adds another vendor relationship for what Supabase Realtime gives you bundled.

Self-hosted Socket.io

Full control, full operational burden. Sticky sessions, scaling, reconnection handling, all on you. Only worth it if you have an existing ops function.

Polling

Simplest possible. Works for low-frequency updates. Ages badly as the team and the data grow.

Want me to build this for you?

Blueprints are how I think. If your problem fits one of these, we are already most of the way to a quote.