All work
P / 01 · 2026
Case study

Polynomial

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.

Project still
Polynomial preview
(I) — Overview

Why Polynomial exists.

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.

Published packages
7
Protocol messages
16
Database tables
8
Runtime services
3
Room roles
3
Tests
174
(II) — Product surface

What the platform includes.

React SDK
  • RoomProvider · useStorage() · useMutation()
  • Presence, others, and broadcast listeners
  • Undo, redo, and history state
  • Connection and sync status for the UI
Core client
  • Framework-agnostic room primitive
  • Immutable JSON storage with path updates
  • Yjs bridge with a pending-update outbox
  • Injectable transport for tests and demos
Server SDK · API
  • Locally signed room and identity tokens
  • Room create, update, upsert, and delete
  • Storage reads and server-side broadcasts
  • Zod-validated request and response shapes
Console · docs
  • Projects, rooms, and storage inspection
  • Key creation, rotation, and revocation
  • Per-project realtime event logs
  • Docs whose demos run the real SDK
(III) Integration

What it looks like
to use.

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.

Backend · @polynomialhq/server
app/api/auth/route.tsts
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 },    }),  });}
Frontend · @polynomialhq/react
providers.tsxtsx
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>;}
canvas.tsxtsx
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 }));  }, []);}
(IV) Architecture

How the system is
put together.

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.

01Backendsecret key02React SDKbrowser room03Workerwebsocket fanout04REST APIrooms · storageSource of truthPostgreSQLrooms · yjs_updatesyjs_snapshots · event logsCoordinationRedisroom channel pub/subone channel per roomroom tokenwss joinappend + snapshotbroadcast_eventpublish · deliverbackend signs, browser joins, worker persists and fans out, api administers
(V) Session flow

From a token
to a live room.

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.

I - AuthorizeII - Join and hydrateIII - Edit and acknowledgeYour backendsecret keyBrowserreact sdkWorkerwebsocketPostgresdurableRedisfanout01who is this user, and may they enter?02HMAC room token · role · 30 min03websocket upgrade · room.join04verify signature, ensure room row05latest snapshot + newer tail only06room.joined · connection id · role07yjs.sync · rebuilds the document08subscribe to the room channel09presence.sync from other nodes10yjs.update · client-generated updateId11append update, return sequence12yjs.ack · outbox drains13same update to every joined socket
(VI) Durable storage

What happens to
one edit.

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.

Write path · one mutationFilled = server · hollow = clientEvery 50 sequencesCompact a snapshotThe document state is written to yjs_snapshots, so alater join replays one snapshot plus the newer tail.01The mutation runs locallyuseMutation writes a path into the immutable JSON snapshot02The JSON is diffed into the CRDTobjects and arrays become granular Y.Map and Y.Array nodes03The update parks in an outboxbase64 update held under a client-generated updateId04The worker checks the rolea read token is rejected before anything is written05Postgres assigns the sequenceappend-only insert into yjs_updates, unique per room06The room document catches upthe worker's in-memory Y.Doc applies the same update07The client is acknowledgedyjs.ack drains the outbox and the room reports synchronized08Everyone else receives itthe update is fanned out to every socket joined to the roomthe sequence number is the contract: it orders the log, drains the outbox, and bounds replay on rejoin
Ordering, enforced by the database
schema.tsts
// 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.

(VII) Scale-out

One room, many
processes.

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.

conn_a1conn_a2conn_a3conn_b1conn_b2Worker node Alocal socketsWorker node Blocal socketsREST APIserver broadcastRedis pub/sub · one channel per roompolynomialhq:realtime:{projectId}:{roomId}publishdeliverbroadcast_eventEnvelope kinds carried on that channelmessagepresence, broadcast, and yjs updatespresence.snapshotwho is already here on other nodesroom.node.joined / leftwhich processes still serve this roomroom.stateconnection counts for scale-out bookkeeping
(VIII) — Wire protocol

A contract both sides import.

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.

Client to server06
  • room.join
  • room.leave
  • presence.update
  • broadcast
  • yjs.update
  • ping
Server to client10
  • room.joined
  • room.left
  • presence.updated
  • presence.sync
  • broadcast
  • yjs.update
  • yjs.sync
  • yjs.ack
  • error
  • pong
(IX) — Room model

What each channel guarantees.

IPresence
  • Lives only while the socket is open
  • New joiners get a presence.sync snapshot
  • Collected across worker processes
  • Never written to Postgres
IIBroadcast
  • Delivered the instant it is sent
  • Never replayed to later joiners
  • Echoed back to the sender by design
  • Also sendable from a trusted backend
IIIStorage
  • Yjs updates, appended with a sequence
  • Survives reconnects and restarts
  • Rebuilt for every new connection
  • The only channel with undo and redo
IVRoles
  • read joins and publishes presence
  • write adds broadcasts and storage
  • admin covers privileged clients
  • Enforced on the worker, not the client
VRecovery
  • Reconnect backs off to a 10s ceiling
  • Unacknowledged updates stay in the outbox
  • Rejoin replays snapshot plus tail
  • Storage status is exposed to the UI
(X) Compatibility

A migration that
touches nothing.

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.

Migration path · the import specifier never changesApplication codeUnchanged@liveblocks/react@liveblocks/client@liveblocks/nodePublished aliasOne re-exportexport * from@polynomialhq/*Polynomial packagesReal runtime@polynomialhq/react@polynomialhq/client@polynomialhq/serverimportsre-exportsSurface held identicalRoomProvideruseOthers · useSelfuseMyPresenceuseStorage · useMutationuseBroadcastEventuseUndo · useRedoLiveObject · LiveMap · LiveListprepareSession · FULL_ACCESS
the whole migrationts
// 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.

(XI) — Data model

Where each kind of state lives.

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.

01Tenancy
  • organizations
  • projects

the workspace boundary every other row hangs off

02Credentials
  • api_keys

sha256 hashes, looked up by a 20-character prefix

03Rooms
  • rooms
  • room_usage

one durable row per project and external room id

04Document log
  • yjs_updates
  • yjs_snapshots

append-only updates plus periodic compacted state

05Observability
  • project_event_logs

joins, leaves, broadcasts, and persisted updates

(XII) — Engineering tradeoffs

What I optimized for.

Strong choices
  • Tokens are signed, not fetched

    Authorization is an HMAC over claims the backend already has, so the auth endpoint never waits on a network call to issue one.

  • Persist before fanout

    An update is written and given a sequence before it reaches anyone else, so nobody sees state the server would lose on restart.

  • Granular CRDT nodes

    JSON is diffed into per-field Yjs structures rather than replaced wholesale, which is the difference between merging concurrent edits and clobbering them.

  • One protocol package, both ends

    Client and server import the same schemas, so a shape change fails at compile time instead of at 3am in production.

Deliberate tradeoffs
  • Base64 JSON frames, not binary

    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.

  • Snapshot history over full undo

    Undo and redo replay storage snapshots rather than inverting CRDT operations. Predictable for a single user, not a shared timeline.

  • Redis is required for scale-out

    Without it the worker still runs, but fanout stops at the process boundary. Single-node deployments work; multi-node ones do not degrade gracefully.

  • Compatibility follows the surface, not the wire

    Liveblocks apps can move by changing imports, but nothing speaks the Liveblocks protocol. The parity is at the API, not the socket.

(XIII) — Risks

Where the design can fail.

  1. 01

    A reconnect that silently drops an edit

    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.

  2. 02

    Two writers, one object

    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.

  3. 03

    A room split across processes

    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.

  4. 04

    An unbounded update log

    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.

(XIV) — Current limits

What the product does not cover yet.

  • Collaboration

    Presence, broadcasts, and storage. No comments, threads, or notification surface.

  • Deployment

    Single-region, single-VM compose stack. No multi-region or failover story shipped.

  • Operations

    Event logs and usage counters exist. No quotas, billing, or per-room rate limiting yet.

(XV) — Intent

What the product sets out to make easier.

Intent
Multiplayer without a CRDT project

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.

Intent
Recovery that is boring

Backoff reconnects, a pending-update outbox, and snapshot-plus-tail hydration mean a dropped connection resolves itself rather than turning into lost state.

Intent
An exit that is one import away

Apps already on Liveblocks can evaluate Polynomial without rewriting components, which turns a migration decision into a reversible experiment.

(XVI) — Key lessons

What I would carry forward.

  1. 01

    The protocol is the architecture

    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.

  2. 02

    Acknowledgement beats optimism

    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.

  3. 03

    Compatibility is a product feature

    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.

  4. 04

    Realtime is mostly reconnect

    The happy path took a fraction of the work. Everything that makes the product feel dependable lives in what happens after the socket closes.

Next case study
P / 02 · 2026

Pigeon

Multi-tenant notifications platform