Glowing terminals connected by threads of light with a gray-blue cat watching from the desk

Hocuspocus: The Backend Half of Real-Time Collaboration

The Gray Cat
The Gray Cat
2 views

If you have ever tried to build a Google-Docs-style editor, you quickly run into two very different problems. The first is merging edits from many people without clobbering each other, which is a genuinely hard distributed-systems puzzle. The second is moving those edits between everyone and storing them somewhere durable. Yjs solves the first problem beautifully with CRDTs, conflict-free replicated data types that let every client converge to the same state on its own. But Yjs is client-side only. It produces and consumes little binary update messages and then politely waits for you to transport them. That transport, plus persistence, authentication, presence, and scaling, is exactly the gap Hocuspocus fills.

Hocuspocus, built by the team behind Tiptap, is a self-hostable WebSocket backend for Yjs. When one user types, Yjs encodes the change, the client provider sends it to the Hocuspocus server, and the server relays it to everyone else editing the same document. It also stores the merged state, seeds new joiners with it, and broadcasts awareness data like cursors and selections. It works with any Yjs-compatible editor binding, including Tiptap, ProseMirror, Slate, Monaco, CodeMirror, and Quill, so it is not tied to a single editor. In short, it is the production-grade backend you would otherwise have to write yourself.

What You Get Out of the Box

The reason teams reach for Hocuspocus instead of the minimal y-websocket reference server is that the batteries are included. The core capabilities worth knowing about:

  • Real-time sync over WebSockets using the standard Yjs sync protocol, so any compliant client just works.
  • Awareness relay for presence: who is online, their cursors, selections, names, and colors.
  • Lifecycle hooks for authentication, loading, change detection, and persistence.
  • Pluggable persistence through a generic Database base class, plus ready-made SQLite and S3 extensions.
  • Horizontal scaling via a Redis pub/sub extension that keeps multiple server instances in sync.
  • Webhooks that fire HMAC-signed HTTP requests on document lifecycle events.
  • Abuse protection through per-IP connection throttling.
  • Multi-runtime support: Node.js 22+, Bun, Deno, and Cloudflare Workers.

That last point is a genuinely modern touch. The default Server class binds a port on Node, but the lower-level Hocuspocus class can attach to any WebSocket-like instance, which is how it slots into Express apps or edge runtimes.

Getting It Installed

The backend lives in @hocuspocus/server:

npm install @hocuspocus/server
# or
yarn add @hocuspocus/server

On the client you need the provider and Yjs itself:

npm install @hocuspocus/provider yjs
# or
yarn add @hocuspocus/provider yjs

If you are building a React UI, add the dedicated bindings:

npm install @hocuspocus/provider @hocuspocus/provider-react yjs

Standing Up Your First Server

The smallest possible server is genuinely tiny. Spin one up, and it will start accepting WebSocket connections and relaying document updates between any clients that ask for the same document name.

import { Server } from "@hocuspocus/server"

const server = new Server({ port: 1234 })

server.listen()
// listening on ws://127.0.0.1:1234

That is a fully functional collaboration backend, but it keeps everything in memory. The moment the process restarts, documents are gone. To make state durable, add a persistence extension. The SQLite one is the fastest way to see real storage working:

import { Server } from "@hocuspocus/server"
import { SQLite } from "@hocuspocus/extension-sqlite"

const server = new Server({
  port: 1234,
  async onConnect() {
    console.log("a client connected")
  },
  extensions: [
    new SQLite({ database: "db.sqlite" }),
  ],
})

server.listen()

Now every document is written to db.sqlite, and a fresh client joining an existing document gets hydrated with the saved state automatically. Extensions are the heart of Hocuspocus: you compose the behavior you need rather than configuring one monolith.

Connecting From the Browser

On the client side, you create a Y.Doc, hand it to a HocuspocusProvider, and from then on any change you make to that document syncs to everyone connected under the same name. The name is effectively the room or document id.

import * as Y from "yjs"
import { HocuspocusProvider } from "@hocuspocus/provider"

const ydoc = new Y.Doc()

const provider = new HocuspocusProvider({
  url: "ws://127.0.0.1:1234",
  name: "example-document",
  document: ydoc,
})

// edits to ydoc now propagate to every client on "example-document"

You rarely use the raw Y.Doc directly. Instead you wire provider.document into your editor binding. With Tiptap, for example, you pass provider.document into the Collaboration extension and the provider itself into CollaborationCaret to get shared cursors. The same provider.awareness powers presence in any binding.

Wiring It Into React

The @hocuspocus/provider-react package gives you components and hooks that handle the lifecycle correctly, including being safe under StrictMode. You wrap a collaborative subtree in a shared websocket component, then mount one HocuspocusRoom per document. Inside the room, hooks expose the provider and its status.

import {
  HocuspocusProviderWebsocketComponent,
  HocuspocusRoom,
  useHocuspocusProvider,
  useHocuspocusConnectionStatus,
} from "@hocuspocus/provider-react"

function Editor() {
  const provider = useHocuspocusProvider()
  const status = useHocuspocusConnectionStatus()
  // status: 'connecting' | 'connected' | 'disconnected'
  // wire provider.document and provider.awareness into your editor here
  return <div>Connection: {status}</div>
}

export function App() {
  return (
    <HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
      <HocuspocusRoom name="example-document" token="super-secret-token">
        <Editor />
      </HocuspocusRoom>
    </HocuspocusProviderWebsocketComponent>
  )
}

There are companion hooks for the bits you usually want to surface in the UI: useHocuspocusConnectionStatus() tells you whether the socket is up, and useHocuspocusSyncStatus() returns 'synced' or 'syncing' so you can show a saving indicator. All of them must be called inside a HocuspocusRoom.

Guarding the Door With Authentication

A collaboration server that anyone can write to is a liability, so authentication is first-class. The client passes a token, and the server validates it in the onAuthenticate hook. Throwing inside that hook rejects the connection.

// client
new HocuspocusProvider({
  url: "wss://collab.example.com",
  name: "example-document",
  document: ydoc,
  token: "super-secret-token",
})
// server
const server = new Server({
  async onAuthenticate({ token, documentName }) {
    const user = await verifyJwt(token)
    if (!user || !user.canAccess(documentName)) {
      throw new Error("Not authorized")
    }
    // anything you return becomes available to later hooks as `context`
    return { user }
  },
})

The token is just a string, so it can carry a JWT, a session id, or whatever your app already uses. The value returned from onAuthenticate flows into subsequent hooks as context, which is how you carry the authenticated user through to loading and persistence.

Bringing Your Own Database

The SQLite extension is great for getting started, but real apps usually have an existing Postgres, MySQL, or MongoDB. The generic Database extension lets you plug in any backend by implementing just two async functions. The state handed to store is a binary Uint8Array of the encoded Yjs update, so you store it as-is and return it unchanged from fetch.

import { Database } from "@hocuspocus/extension-database"

new Database({
  fetch: async ({ documentName }) => {
    return await myDb.loadDocument(documentName) // Uint8Array or null
  },
  store: async ({ documentName, state }) => {
    await myDb.saveDocument(documentName, state)
  },
})

Storage is typically debounced under the hood, so you are not writing to disk on every keystroke. If you load initial content from your own tables rather than the encoded blob, the onLoadDocument hook lets you build and return a Y.Doc to seed the document from scratch, which is handy when migrating existing content into a collaborative format.

Scaling Out and Staying Notified

A single Node process can carry a surprising number of connections, but eventually you want more than one instance behind a load balancer. The catch is that two users on different instances editing the same document would never see each other. The Redis extension solves this by broadcasting updates and awareness over pub/sub so every instance stays in sync.

import { Redis } from "@hocuspocus/extension-redis"

const server = new Server({
  extensions: [
    new Redis({ host: "127.0.0.1", port: 6379 }),
    // ...plus a persistence extension, Redis does not store anything long-term
  ],
})

Redis only handles real-time cross-instance relay, so you still need a persistence extension for durable storage; the two work together. It also uses distributed locking so two instances do not race when writing the same document.

For integrating with the rest of your system, the webhook extension fires signed HTTP requests on lifecycle events, which is perfect for search indexing, audit logs, or downstream pipelines without writing a custom extension.

import { Webhook, Events } from "@hocuspocus/extension-webhook"

new Webhook({
  url: "https://example.com/hocuspocus-webhook",
  secret: "your-signing-secret",
  events: [Events.onChange, Events.onConnect, Events.onDisconnect],
})

Every request is HMAC-SHA256 signed using your secret and sent in the X-Hocuspocus-Signature-256 header, so the receiver can verify it really came from your server. The onChange event is debounced by default to avoid a request per keystroke, and the window is tunable. Round out a production deployment with @hocuspocus/extension-throttle to rate-limit connections per IP and you have a server that survives reconnect storms and abuse.

Where It Fits

Hocuspocus sits in a crowded but distinct spot. Compared to the canonical y-websocket, it trades minimalism for a batteries-included feature set: auth hooks, pluggable persistence, webhooks, Redis scaling, throttling, and multi-runtime support all ship in the box. Against hosted platforms like Liveblocks it stays open-source and self-hostable, with the optional Tiptap Collab cloud if you would rather not run servers at all. And next to peer-to-peer options like y-webrtc, it gives you the central authority you need for reliable persistence and access control.

If you are building collaborative editing, especially on Tiptap or ProseMirror, and you want authentication, durable storage, and horizontal scaling without inventing your own sync protocol, Hocuspocus is the pragmatic default. Yjs handles the hard math of conflict resolution; Hocuspocus quietly handles everything else. Start with the three-line server, add a persistence extension, lock the door with onAuthenticate, and you have a real-time collaboration backend that you actually understand top to bottom.