Realtime collaboration infrastructure
A realtime collaboration platform for React apps: presence, broadcast events, and CRDT-backed durable storage over one WebSocket protocol, published as SDKs that are API-compatible with Liveblocks.

Multiplayer is the feature teams keep postponing. Not because cursors are hard, but because everything underneath them is: ordering, persistence, reconnects, and fanout across processes.
Polynomial is a hosted realtime collaboration service for React applications. A room gives a surface three things: who is here, what just happened, and what is durably shared. The SDK hides the WebSocket, the CRDT, and the recovery path behind hooks that look like ordinary state.
It also ships as a drop-in replacement for Liveblocks. The same provider, the same hooks, the same server helpers, published under the import paths an existing app already uses. Migration is a resolution change, not a rewrite.
The backend keeps one job: decide who may enter a room and sign a short-lived token. Everything else is a provider and a few hooks. Shared state reads like local state, and mutations look like the reducer the app would have written anyway.
import { PolynomialServer } from "@polynomialhq/server"; const polynomial = new PolynomialServer({ secretKey: process.env.POLYNOMIAL_SECRET_KEY!,}); // Your app decides who may enter. Signing is local, with no round trip.export async function POST(request: Request) { const { room } = await request.json(); const user = await requireSession(request); return Response.json({ token: polynomial.authorizeRoom({ projectId: process.env.POLYNOMIAL_PROJECT_ID!, roomId: room, role: "write", userId: user.id, userInfo: { name: user.name }, }), });}import { createClient, PolynomialProvider } from "@polynomialhq/react"; // One client for the app, created outside renderconst client = createClient({ authEndpoint: "/api/polynomial-auth" }); export function Providers({ children }) { return <PolynomialProvider client={client}>{children}</PolynomialProvider>;}import { RoomProvider, useMutation, useOthers, useStorage } from "@polynomialhq/react"; function Canvas() { const blocks = useStorage((storage) => storage.blocks); const others = useOthers(); const move = useMutation(({ storage }, id, x, y) => { storage.updateAt(["blocks", id], (block) => ({ ...block, x, y })); }, []);}Two services, split by the shape of their traffic. A realtime worker holds long-lived WebSocket connections and owns the room document. A stateless REST API handles administration, storage reads, and server-sent broadcasts. Postgres is the only source of truth; Redis carries nothing that cannot be rebuilt.
A join is not one step, it is a handshake. Verify the signature, guarantee the room row exists, rebuild the document from a snapshot plus the newer tail, then start streaming. Only after that does an edit get a sequence number and an acknowledgement.
Application code writes plain JSON. Underneath, that JSON is diffed into granular Yjs nodes so two people editing different fields of the same object do not overwrite each other. The update is held in an outbox until the server returns a sequence number, which is what lets the UI honestly say whether it is synchronized.
// One partial unique index makes a room's log strictly ordereduniqueIndex("yjs_updates_room_sequence_idx") .on(table.roomId, table.sequence); uniqueIndex("yjs_updates_room_update_id_idx") .on(table.roomId, table.updateId) .where(sql`${table.updateId} is not null`);The room-scoped sequence is the contract the rest of the system leans on. It orders the append-only log, tells a rejoining client which tail it still needs after a snapshot, and gives the outbox something concrete to wait for. The second index makes a retried update collapse into the write it already made.
Two people in the same room routinely land on different worker processes. Each room gets a Redis channel, and the same channel carries more than message fanout: it answers presence snapshot requests, tracks which nodes still serve the room, and gives the REST API a way to broadcast into a room it holds no socket for.
The protocol lives in its own package as Zod schemas, and both the browser and the worker parse against them. A malformed frame is rejected at the boundary with a typed error code rather than reaching handler logic, and a version field on every message leaves room to change the shape later without breaking older clients.
The hard part of any infrastructure product is not the first install, it is the switching cost of the one already in place. Polynomial answers that by publishing thin alias packages that re-export its own SDKs under the Liveblocks import paths, and by keeping the hook and helper names identical underneath.
// The same app, running on Polynomial. Only resolution changed.import { RoomProvider, useOthers } from "@liveblocks/react"; // packages/liveblocks-react/src/index.tsexport * from "@polynomialhq/react";The compatibility layer is deliberately thin: three packages that do nothing but re-export. The parity work lives in the real SDKs, where a Liveblocks-shaped session builder, access constants, and the LiveObject, LiveMap, and LiveList structures are implemented against Polynomial's own room model.
Rooms are addressed by the application's own identifier, unique per project, so a customer never has to store a mapping. Document history is an append-only log with periodic compaction rather than a mutable blob, which keeps writes cheap and makes recovery a replay instead of a repair.
the workspace boundary every other row hangs off
sha256 hashes, looked up by a 20-character prefix
one durable row per project and external room id
append-only updates plus periodic compacted state
joins, leaves, broadcasts, and persisted updates
Authorization is an HMAC over claims the backend already has, so the auth endpoint never waits on a network call to issue one.
An update is written and given a sequence before it reaches anyone else, so nobody sees state the server would lose on restart.
JSON is diffed into per-field Yjs structures rather than replaced wholesale, which is the difference between merging concurrent edits and clobbering them.
Client and server import the same schemas, so a shape change fails at compile time instead of at 3am in production.
Yjs updates ride inside JSON messages. It costs bandwidth, and it buys a protocol that is readable in a network tab and validated by one schema package.
Undo and redo replay storage snapshots rather than inverting CRDT operations. Predictable for a single user, not a shared timeline.
Without it the worker still runs, but fanout stops at the process boundary. Single-node deployments work; multi-node ones do not degrade gracefully.
Liveblocks apps can move by changing imports, but nothing speaks the Liveblocks protocol. The parity is at the API, not the socket.
The socket closes mid-write more often than anyone plans for. Unacknowledged updates have to survive the gap and be replayed on rejoin, or a user loses work they watched appear on screen.
Whole-object replacement is the easy implementation and the wrong one. Concurrent edits to different fields have to merge, which is why the JSON layer is diffed into granular CRDT nodes.
Presence and broadcasts are only correct if every node serving a room can reach the others. New connections also need to learn about people who were already there on a different process.
Append-only writes are cheap until a rejoin has to replay a hundred thousand of them. Periodic snapshot compaction is what keeps join latency flat as a document ages.
Presence, broadcasts, and storage. No comments, threads, or notification surface.
Single-region, single-VM compose stack. No multi-region or failover story shipped.
Event logs and usage counters exist. No quotas, billing, or per-room rate limiting yet.
A provider, a room, and two hooks. The document model, the socket, and the merge semantics stay inside the SDK instead of becoming the consuming team's problem.
Backoff reconnects, a pending-update outbox, and snapshot-plus-tail hydration mean a dropped connection resolves itself rather than turning into lost state.
Apps already on Liveblocks can evaluate Polynomial without rewriting components, which turns a migration decision into a reversible experiment.
Writing the message schemas before either side existed forced the hard questions early. What does a join guarantee? What does an acknowledgement mean? The services mostly fell out of the answers.
The moment the server started returning a sequence per update, the client could tell the truth about sync state, retry safely, and stop guessing whether a reconnect had cost anything.
Matching an incumbent's API is not imitation, it is removing the largest reason a team says no. Three re-export packages did more for adoption than any feature would have.
The happy path took a fraction of the work. Everything that makes the product feel dependable lives in what happens after the socket closes.
Multi-tenant notifications platform