projects-md.ai
Adding offline-first realtime collaboration to a shipped product.
project-overview-cyan.vercel.app
Screenshot placeholder — two browsers, live cursors, concurrent reorder
projects-md.ai is my personal project tracker — a checklist for every side-project I have in flight. Built on Next.js + Supabase, magic-link auth, shared workspaces with one collaborator. The notes for each project were technically shared, but you only saw each other's edits after refreshing.
I wanted to fix that. Specifically: when two people are looking at the same project, they should see each other working live. And if the wifi flakes, neither side should lose work.
This post is about how that went, and the two things that almost stopped it.
The non-decision: multi-cursor
Standard playbook for a collaboration upgrade: Yjs + TipTap + multi-cursor in a rich-text doc. The case study every other case study points at.
I almost built it. Then I noticed: the core product is a checklist, not a document. Multi-cursor on rich text looks impressive in a demo and disappears in normal usage. What's actually interesting about checklists when two people are collaborating:
- Both can add items at the same time without losing either
- Both can reorder the list at the same time without one reorder clobbering the other
- Edits made on a plane reconcile cleanly on landing
The showcase pivoted: from "multi-cursor on text" to "concurrent reorder + offline-first". Same CRDT primitives, more practically useful.
The architecture
The stack:
- Yjs for the CRDT — mature, the right primitives
- Supabase Realtime Broadcast as the transport — already in the dependency tree, no extra service
- Postgres
byteacolumn as the durable snapshot, debounced from clients - y-indexeddb for offline cache
- Supabase Presence (separate channel) for the live avatars
Zero new deployable services. The whole upgrade is Next.js on Vercel + Supabase, the same stack as before. My original plan called for self-hosted Hocuspocus on Railway; once I actually read the codebase, ~150 lines of Yjs ↔ Broadcast glue replaced an entire managed service.
That savings is the actual lesson, not the algorithm choice. The right architecture for this product wasn't a managed Yjs server — it was leaning into what was already there.
The hard parts
Two things bit me. Both came from "obvious" first attempts that turned out to be subtly wrong.
1. Naïve CRDT seeding races
The naïve flow: on first load, check if a Yjs doc exists in Postgres. If not, build one client-side from the legacy notes rows. Save.
Works perfectly with one user. With two users hitting "first load" within the same minute, both clients independently build a Yjs doc from the same legacy rows, each with their own Yjs clientID. When those docs later sync over Broadcast, Yjs treats each side's contribution as independent CRDT inserts. Two collaborators. Identical content. Double entries everywhere.
The fix is server-side atomic seeding: the server constructs the initial Yjs doc bytes, then INSERT ... ON CONFLICT DO NOTHING into Postgres. Whoever wins becomes canonical. Every other client reads the same bytes. One source, no race.
Belt-and-suspenders: a compactDuplicates() pass on every doc load that removes duplicate-id entries if they ever do slip in. It propagates via the same Broadcast channel, so one client healing fixes all peers.
2. Offline detection is unreliable
Supabase Realtime auto-reconnects. In theory: disconnect, channel notices, status becomes CHANNEL_ERROR, reconnect, status becomes SUBSCRIBED again, your code re-broadcasts the offline edits.
In practice: TCP buffering means the WebSocket layer often doesn't notice the disconnect. You make offline edits, the SDK send()s them into a dead socket, returns "ok", your peers never see them. Wifi comes back. SDK reuses the same channel. No SUBSCRIBED event fires. No re-broadcast.
The fix: listen to window.online and aggressively rebuild the channel. Tear down the existing one, create a new one, force a fresh SUBSCRIBED callback — which broadcasts the full Yjs state, catching peers up on offline edits. Plus an immediate server snapshot so the offline work lands in durable storage.
What changed for users
- Live presence avatars on both the dashboard cards and the project view
- Notes, links, and their order — all sync in real time
- Drag-reorder notes, with concurrent reorders by both collaborators merging cleanly
- An offline indicator when the connection drops
- A "Reconnected" badge when it comes back
- The app keeps working with wifi off; edits reconcile on reconnect
What I'd do differently
- Test the offline reconnect path before adding more features. I shipped real-time sync without verifying the offline reconnect, and only caught the bug when my collaborator reported "offline edits don't sync" days later. The fix was small but the diagnosis was a detour.
- Server-side seed from day one. I shipped client-side seeding and immediately had to ship the fix. The right architecture was obvious in retrospect.
Both lessons are general: when there's a possible race, prove there isn't one before shipping. Don't ask "will this race in practice" — assume it will.
The demo
Two browsers, same project, both members. Add notes. Drag them around. Watch them reflect on the other side within ~200ms. Turn off wifi on one. Keep editing. Turn wifi back on. The other browser catches up within a second.
That's the showcase. No new services, no managed-CRDT SaaS. Just Yjs over the Supabase channels that were already there.
Want the engineering deep-dive? A technical teardown — actual code, edge cases, the 150-line provider, the atomic seed SQL — is forthcoming on my Substack. Until then, try it live at project-overview-cyan.vercel.app.