Skip to content
October 3, 2026•9 min read•By Dzaki Amri Zaidaan

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.

#Frontend#Architecture
black flat screen tv turned on displaying UNK

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:

  1. User types → React handler calls doc.getMap('cells').set(id, value).
  2. Yjs computes a minimal binary delta and emits an update event synchronously.
  3. The React subscription re-renders only the affected subtree (via a selector-based store, not a global re-render).
  4. The sync engine queues the delta and flushes it over the WebSocket.
  5. 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:

  • useSyncExternalStore over useState + useEffect. Under React 18 concurrent rendering, the effect-based pattern can tear — the component reads a value that's already stale mid-render. useSyncExternalStore is 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 keysChanged scopes invalidation to the touched key.
  • Module-scoped doc registry. Creating the Y.Doc inside a component (or useMemo) 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 useSyncExternalStore for 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.

References