All posts
12 Sep 2026

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:

  1. 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.
  2. Real-time Syncing: Broadcast local updates to peers via WebSockets and apply incoming remote updates to both the Yjs model and IndexedDB.
  3. 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.
code
[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 saveDocumentState in a debounce or requestIdleCallback utility 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 Uint8Array updates 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.

More posts