Offline-First Collaboration: Persisting Yjs and Editor.js State with IndexedDB
A practical, code-heavy guide to implementing client-side persistence and seamless state recovery for Yjs and Editor.js collaborative editors using IndexedDB.
Offline-First Collaboration: Persisting Yjs and Editor.js State with IndexedDB
Modern web applications demand real-time collaboration. Tools like Google Docs, Figma, and Notion have shifted user expectations: things should just sync, update instantly, and never lose state—even when the Wi-Fi drops out midway through a sentence.
Building a collaborative editor typically involves pairing a CRDT (Conflict-free Replicated Data Type) library like Yjs with a block-based editor like Editor.js. But what happens when the user closes their laptop on a flight, makes edits locally, and reconnects hours later? Without a robust offline-first persistence layer, those local changes vanish into the ether, or worse, cause catastrophic state desynchronization.
In this guide, we will build a production-ready persistence layer using IndexedDB, wire it up to a Yjs document, and bind it to Editor.js to handle offline state recovery and seamless remote merging.
Architecture Overview
To achieve true offline-first collaboration, our architecture needs to handle three distinct lifecycle stages:
- Local Bootstrapping: Load the last known Yjs document state from IndexedDB instantly upon page load, allowing the user to edit immediately without waiting for a WebSocket handshake.
- Real-time Syncing: Broadcast local updates to peers via WebSockets and apply incoming remote updates to both the Yjs model and IndexedDB.
- Offline Mutation & Reconciliation: Persist every local transaction to IndexedDB. Upon reconnection, Yjs automatically handles vector clock comparisons and conflict-free merges with the remote server state.
[Editor.js] <---> [Yjs Doc] <---> [IndexedDB (Local Persistence)]
^
| (WebSocket / CRDT Sync)
[Remote Server]
Setting Up the Dependencies
First, let’s install the required packages. We’ll use yjs, editorjs, and idb (a lightweight wrapper around IndexedDB that makes working with promises a breeze).
npm install yjs @editorjs/editorjs idb
Step 1: Initializing IndexedDB Persistence for Yjs
Yjs updates are represented as binary updates (Uint8Array). Instead of storing the raw JSON of our editor, we store the compact Yjs update history or the encoded document state vector in IndexedDB.
Let’s create a storage utility using the idb library to read and write Yjs binary state blobs.
import { openDB, DBSchema, IDBPDatabase } from 'idb';
import * as Y from 'yjs';
interface EditorDB extends DBSchema {
documents: {
key: string;
value: Uint8Array;
};
}
const DB_NAME = 'collab-editor-db';
const STORE_NAME = 'documents';
export async function getDatabase(): Promise<IDBPDatabase<EditorDB>> {
return openDB<EditorDB>(DB_NAME, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
},
});
}
/**
* Saves the current binary state of the Y.Doc to IndexedDB
*/
export async function saveDocumentState(docId: string, ydoc: Y.Doc): Promise<void> {
const db = await getDatabase();
const stateVector = Y.encodeStateAsUpdate(ydoc);
await db.put(STORE_NAME, stateVector, docId);
}
/**
* Loads the saved Y.Doc state from IndexedDB if it exists
*/
export async function loadDocumentState(docId: string, ydoc: Y.Doc): Promise<void> {
const db = await getDatabase();
const persistedState = await db.get(STORE_NAME, docId);
if (persistedState) {
Y.applyUpdate(ydoc, persistedState);
}
}
Step 2: Integrating Yjs with Editor.js
Editor.js manages its own internal state as a JSON object containing an array of blocks. To bridge Editor.js with Yjs, we map Editor.js blocks to a Yjs XML fragment or a Yjs Map/Array structure. For block-based editors, a Y.Map or Y.Array representing the block list is ideal.
Let’s write a synchronization binder that listens to changes from Editor.js and pushes them to Yjs, while also listening to remote Yjs updates and updating Editor.js.
import EditorJS, { OutputData } from '@editorjs/editorjs';
import * as Y from 'yjs';
export class YjsEditorBinding {
private ydoc: Y.Doc;
private editor: EditorJS;
private yblocks: Y.Array<any>;
private isUpdatingRemote = false;
constructor(ydoc: Y.Doc, editor: EditorJS, docId: string) {
this.ydoc = ydoc;
this.editor = editor;
this.yblocks = this.ydoc.getArray(`${docId}-blocks`);
// 1. Observe changes from remote peers or local Yjs transactions
this.yblocks.observe(async () => {
if (this.isUpdatingRemote) return;
this.isUpdatingRemote = true;
const blocks = this.yblocks.toJSON() as OutputData['blocks'];
await this.editor.render({ blocks, time: Date.now() });
this.isUpdatingRemote = false;
});
}
/**
* Call this when local Editor.js content changes
*/
public async handleEditorChange(): void {
if (this.isUpdatingRemote) return;
this.isUpdatingRemote = true;
const savedData = await this.editor.save();
this.ydoc.transact(() => {
this.yblocks.delete(0, this.yblocks.length);
this.yblocks.insert(0, savedData.blocks);
});
this.isUpdatingRemote = false;
}
}
Step 3: Handling Network Reconnections and WebSocket Sync
When a user is offline, updates accumulate locally in IndexedDB and the in-memory Y.Doc. When the WebSocket connection re-establishes, we need a sync protocol to exchange state vectors and missing updates with the server.
Here is how we coordinate WebSocket reconnection, state persistence throttling, and synchronization:
import * as Y from 'yjs';
import { saveDocumentState } from './db';
export class SyncManager {
private ws: WebSocket | null = null;
private ydoc: Y.Doc;
private docId: string;
private serverUrl: string;
private reconnectInterval = 3000;
constructor(ydoc: Y.Doc, docId: string, serverUrl: string) {
this.ydoc = ydoc;
this.docId = docId;
this.serverUrl = serverUrl;
// Listen to all local and remote changes on the Y.Doc
this.ydoc.on('update', async (update: Uint8Array, origin: any) => {
// 1. Persist every update locally to IndexedDB
await saveDocumentState(this.docId, this.ydoc);
// 2. Broadcast over WebSocket if connected
if (this.ws && this.ws.readyState === WebSocket.OPEN && origin !== 'server') {
this.ws.send(update);
}
});
this.connect();
}
private connect() {
this.ws = new WebSocket(`${this.serverUrl}?room=${this.docId}`;
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
console.log('[Sync] WebSocket connected. Sending state vector...');
// Send local state vector to server so it can calculate missing updates
const stateVector = Y.encodeStateVector(this.ydoc);
this.ws?.send(stateVector);
};
this.ws.onmessage = (event) => {
const buffer = new Uint8Array(event.data);
// Apply remote updates to our Y.Doc with origin 'server'
Y.applyUpdate(this.ydoc, buffer, 'server');
};
this.ws.onclose = () => {
console.warn('[Sync] WebSocket disconnected. Operating offline...');
setTimeout(() => this.connect(), this.reconnectInterval);
};
this.ws.onerror = (error) => {
console.error('[Sync] WebSocket error:', error);
this.ws?.close();
};
}
}
Pro-Tip on Throttling: Writing to IndexedDB on every single keystroke can degrade performance on low-end mobile devices. Wrap
saveDocumentStatein adebounceorrequestIdleCallbackutility to batch writes happening within a 500ms window.
Step 4: Putting It All Together in the Application Entry Point
Now let’s wire everything up inside our main application component. We initialize the Y.Doc, load any persisted offline state from IndexedDB before mounting Editor.js, and then activate our synchronization manager.
import EditorJS from '@editorjs/editorjs';
import Header from '@editorjs/header';
import List from '@editorjs/list';
import * as Y from 'yjs';
import { loadDocumentState } from './db';
import { YjsEditorBinding } from './binding';
import { SyncManager } from './sync';
async function initEditor() {
const docId = 'document-xyz-123';
const ydoc = new Y.Doc();
// 1. Load offline state from IndexedDB first
await loadDocumentState(docId, ydoc);
// 2. Initialize Editor.js
const editor = new EditorJS({
holder: 'editorjs',
tools: {
header: Header,
list: List,
},
// Seed initial blocks from Yjs if already present
data: {
blocks: ydoc.getArray(`${docId}-blocks`).toJSON() || [],
},
onChange: async () => {
await binding.handleEditorChange();
},
});
await editor.isReady;
// 3. Bind Yjs to Editor.js
const binding = new YjsEditorBinding(ydoc, editor, docId);
// 4. Start WebSocket Sync & Offline Persistence Manager
const syncManager = new SyncManager(ydoc, docId, 'wss://collab.example.com/ws');
console.log('Collaborative editor initialized with offline-first persistence.');
}
initEditor().catch(console.error);
Handling Edge Cases and Conflict Resolution
When building offline-first systems, you will inevitably encounter edge cases. Here is how our architecture handles them:
1. Storage Quota Exceeded
IndexedDB has storage limits dependent on available disk space and browser quotas. Implement storage estimation checks:
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const percentageUsed = (estimate.usage! / estimate.quota!) * 100;
if (percentageUsed > 80) {
console.warn('Storage quota is nearing capacity!');
// Trigger cleanup of old document history or prompt user
}
}
2. Concurrent Offline Edits
Because Yjs uses state-based and operation-based CRDTs under the hood, two users can edit the exact same paragraph while offline. When they reconnect, Yjs deterministically interleaves or merges the text changes based on Lamport timestamps without throwing merge conflicts.
Conclusion
By combining Yjs, Editor.js, and IndexedDB, you get the best of all worlds: instantaneous local loads, resilient offline editing, and effortless real-time synchronization.
Key Takeaways
- IndexedDB is your source of truth on the client: Always load local state before rendering your UI to eliminate blank loading screens.
- Treat CRDT updates as immutable logs: Storing binary
Uint8Arrayupdates or state vectors in IndexedDB is extremely efficient. - Decouple transport from storage: Let your WebSocket layer push updates as they arrive, while your persistence layer quietly commits every local transaction in the background.