Shipping a Crowdsourced Frontend Platform: Architecture, Trade-offs, and Lessons from Building Crowdwide
A production-grade teardown of building Crowdwide, an open-source crowdsourced frontend platform. Covers the real-time sync architecture, optimistic UI with conflict resolution, bundle-size discipline, and the engineering trade-offs that matter when you ship in public.
The Problem & Industry Shift
"Almost done" is the most dangerous phrase in software. For weeks I've been building Crowdwide — an open-source, crowdsourced frontend platform where many contributors edit shared UI state in real time — and the last 10% took as long as the first 90%. This article is the technical postmortem: what actually shipped, what broke, and the architecture decisions that survived contact with real users.
The industry shift driving this is concrete. Traditional CRUD frontends assume a single writer and a server that is the source of truth. That model collapses the moment two users touch the same document. The last decade of frontend engineering has been a slow migration toward local-first architectures: the client holds authoritative state, mutations are applied optimistically, and a sync layer reconciles divergent replicas [1]. Tools like Yjs, Automerge, and the CRDT ecosystem made this tractable, but they push enormous complexity into the frontend: conflict resolution, presence, offline queues, and deterministic merge semantics now live in the browser.
The previous approach — polling a REST endpoint and re-rendering the whole tree — doesn't just feel slow. It's architecturally wrong for collaboration. It produces lost updates (last-write-wins silently discards concurrent edits), thundering-herd load on your API, and a UI that lies to the user about the state of the world. Crowdwide's core bet was that a CRDT-backed local-first model, wrapped in a disciplined React layer, is the correct foundation.
Architecture & Core Mechanics
The system has four layers. The critical insight is that the network is an optimization, not a dependency — the app must be fully functional offline and reconcile later.
┌─────────────────────────────────────────────────────────────┐
│ React UI (optimistic, renders from local doc) │
│ ▲ applyLocalChange() ▼ subscribe(doc, cb) │
├─────────────────────────────────────────────────────────────┤
│ CRDT Document Store (Yjs Y.Doc) │
│ • Y.Map / Y.Array → conflict-free merges │
│ • update events → binary deltas (Uint8Array) │
├─────────────────────────────────────────────────────────────┤
│ Sync Engine │
│ • WebSocket transport (y-websocket protocol) │
│ • IndexedDB persistence (y-indexeddb) for offline │
│ • Awareness protocol → cursors, presence, who's editing │
├─────────────────────────────────────────────────────────────┤
│ Backend: stateless WS relay + Postgres snapshot store │
│ • relays binary deltas, does NOT interpret them │
│ • periodic compaction → durable snapshot │
└─────────────────────────────────────────────────────────────┘
Data flow for a single keystroke:
- User types → React handler calls
doc.getMap('cells').set(id, value). - Yjs computes a minimal binary delta and emits an
updateevent synchronously. - The React subscription re-renders only the affected subtree (via a selector-based store, not a global re-render).
- The sync engine queues the delta and flushes it over the WebSocket.
- The server relays the opaque bytes to all peers; each peer merges deterministically.
The server never parses the CRDT. This is deliberate: it keeps the relay stateless, horizontally scalable, and cheap. The cost is that the server can't validate semantics — authorization must happen at the connection/document level, not per-operation.
Why CRDTs over OT (Operational Transform): OT requires a central server to transform operations against a canonical history, which reintroduces the server as a hard dependency and a scaling bottleneck. CRDTs converge without coordination, which is exactly what an offline-tolerant, peer-heavy crowdsourced app needs [1]. The trade-off is metadata overhead — CRDTs carry tombstones and logical clocks that grow with edit history, which is why compaction matters (see below).
Production Code Example
The hard part isn't the happy path — it's making React render efficiently against a mutable CRDT and handling the offline→online transition without losing edits. Here's the core store hook, TypeScript, trimmed to the decisions that matter:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
import { useEffect, useSyncExternalStore, useCallback } from 'react';
// Single shared doc per room. Created once, module-scoped — NOT in a component,
// otherwise every remount forks the CRDT and you get silent divergence.
const docs = new Map<string, Y.Doc>();
function getDoc(roomId: string): Y.Doc {
let doc = docs.get(roomId);
if (!doc) {
doc = new Y.Doc();
// Offline-first: hydrate from IndexedDB before the socket even connects.
new IndexeddbPersistence(`crowdwide:${roomId}`, doc);
const provider = new WebsocketProvider(WS_URL, roomId, doc, {
// Backoff is handled by y-websocket; cap reconnect churn on flaky networks.
maxBackoffTime: 10_000,
});
// Expose awareness for presence/cursors without leaking it into React state.
(doc as any).__provider = provider;
docs.set(roomId, doc);
}
return doc;
}
/**
* Subscribe a component to a slice of the CRDT.
* useSyncExternalStore is the correct primitive here: it guarantees tear-free
* reads under concurrent rendering, which a naive useEffect+useState does not.
*/
export function useCell(roomId: string, cellId: string): string {
const doc = getDoc(roomId);
const map = doc.getMap<string>('cells');
const subscribe = useCallback(
(onStoreChange: () => void) => {
// Only fire for this key — avoids O(n) re-renders on unrelated edits.
const observer = (event: Y.YMapEvent<string>) => {
if (event.keysChanged.has(cellId)) onStoreChange();
};
map.observe(observer);
return () => map.unobserve(observer);
},
[map, cellId]
);
const getSnapshot = useCallback(() => map.get(cellId) ?? '', [map, cellId]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* Optimistic write. The CRDT applies locally and synchronously, so the UI is
* instant; the network flush happens in the background. No rollback logic is
* needed because CRDT merges are commutative — a concurrent remote edit
* converges rather than clobbering.
*/
export function writeCell(roomId: string, cellId: string, value: string): void {
const doc = getDoc(roomId);
doc.getMap<string>('cells').set(cellId, value);
}
Three decisions carry the weight here:
useSyncExternalStoreoveruseState+useEffect. Under React 18 concurrent rendering, the effect-based pattern can tear — the component reads a value that's already stale mid-render.useSyncExternalStoreis purpose-built for external mutable sources and guarantees a consistent snapshot [2].- Per-key observation. Subscribing to the whole map and diffing in the component causes every cell to re-render on every keystroke anywhere in the document. Observing
keysChangedscopes invalidation to the touched key. - Module-scoped doc registry. Creating the
Y.Docinside a component (oruseMemo) forks the CRDT on remount, producing two replicas that never merge. This is the single most common bug in CRDT-backed React apps.
Performance, Cost & Trade-offs
Bundle size. Yjs core is ~40KB minified+gzipped; y-websocket and y-indexeddb add ~10KB combined. That's real, but it replaces an entire class of application code (diffing, retry queues, conflict UI). The honest trade-off: if your app has a single writer, you're paying 50KB for nothing. Don't reach for CRDTs reflexively.
Memory growth. CRDT metadata grows with edit history — tombstones for deleted items, per-client logical clocks. A document edited by 50 users over a week can carry megabytes of history. Mitigation is compaction: periodically snapshot the materialized state to Postgres and start a fresh Y.Doc from that snapshot, discarding history. We run this on a schedule and on document-close when the room empties. Without compaction, long-lived rooms leak memory on both client and server.
Latency vs. consistency. Optimistic local application gives sub-16ms perceived latency regardless of network RTT. The cost is that users can briefly see divergent state during partition — two users may see different values for the same cell for a few hundred milliseconds until deltas exchange. For a collaborative editor this is acceptable and expected; for a payments ledger it is not. Match the consistency model to the domain.
Server cost. Because the relay is stateless and treats deltas as opaque bytes, a single node handles thousands of concurrent rooms with modest CPU. The expensive operation is snapshotting to Postgres, which we batch and rate-limit. Horizontal scaling is trivial: sticky sessions aren't required because any node can relay to any peer in a room via a pub/sub backplane (we use Redis pub/sub).
Security. The relay can't validate operations, so authorization is coarse-grained: a token gates which rooms a connection may join. Fine-grained per-cell permissions are not enforceable at the relay without parsing CRDTs, which defeats the architecture. If your threat model requires per-field ACLs, CRDTs are the wrong tool — use a server-authoritative model instead.
Actionable Checklist / Summary
If you're adopting a local-first, CRDT-backed frontend in production:
- Confirm you actually have concurrent writers. Single-writer apps should use plain REST/GraphQL. CRDTs are a tax you pay for convergence you don't need.
- Create the document once, outside React. Module-scoped registry or a context provider — never
useMemo/useState. Forked docs are silent data loss. - Use
useSyncExternalStorefor any external mutable store. It's the only tear-free primitive under concurrent rendering [2]. - Scope subscriptions to the smallest unit (per-key, per-field). Global subscriptions turn every edit into a full-tree render.
- Implement compaction before you need it. Snapshot to durable storage and reset history on a schedule; CRDT metadata grows unbounded otherwise.
- Keep the relay stateless and opaque. Push authorization to the connection layer; don't parse CRDTs server-side.
- Test with real network conditions. Offline→online transitions and concurrent edits from two clients are where the bugs live. Simulate with throttling and forced disconnects.
- Measure bundle impact. Budget the ~50KB and justify it against the application code it replaces.
Crowdwide isn't "done" — nothing is. But it's shipped, it converges under concurrent edits, and it survives going offline. That's the bar that matters.