---
title: "Live Collaborative Editing in Astro with Cloudflare Durable Objects"
description: "Build Google Docs-style real-time collaboration in Astro using Cloudflare Durable Objects. Implement operational transforms, conflict resolution, and cursor tracking with persistent WebSocket connections at the edge."
source: "https://www.launchfa.st/blog/astro-collaborative-editing-durable-objects"
author: "Rishi Raj Jain"
created_at: 2025-12-09T14:00:00.000Z
updated_at: 2026-07-02T00:00:00.000Z
keywords: "astro, cloudflare, durable objects, collaborative editing, operational transforms, real-time, websockets, crdt"
---

> **Site index for agents**
> Fetch https://www.launchfa.st/llms.txt to discover every page on this site.
> Every page is also available as Markdown by appending `.md` to its URL.

<img width="1600" height="900" alt="Live Collaborative Editing in Astro with Cloudflare Durable Objects" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/astro-collaborative-editing-durable-objects.png" />

Building Google Docs-style collaborative editing means solving distributed state synchronization, handling concurrent edits, and managing conflict resolution. Traditional approaches require operational transform servers, CRDT libraries, or managed services like Yjs with Y-WebSocket servers, all of which are complex to set up and expensive to scale.

[Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) simplify this by providing strongly consistent, stateful objects at the edge that handle WebSocket connections natively. Each document gets its own Durable Object instance with built-in storage and single-threaded execution, eliminating race conditions and making conflict resolution straightforward.

In this guide, you'll build a collaborative text editor in Astro that syncs changes in real-time across multiple users. You'll implement change broadcasting, cursor tracking, and document persistence using Durable Objects with WebSocket Hibernation for cost efficiency.

- [View example project on GitHub](https://github.com/rishi-raj-jain/my-astro-collab-editor)

## Demo

<video controls class="rounded aspect-video" autoplay="false">
    <source src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/Screen%20Recording%202025-12-10%20at%201.24.46%E2%80%AFAM.mp4" type="video/mp4">
</video>

## Prerequisites

You'll need:

- [Node.js 20](https://nodejs.org/en/blog/announcements/v20-release-announce) or later
- A [Cloudflare](https://dash.cloudflare.com/sign-up) account

## Architecture overview

```
1. Editor Page (src/pages/doc/[id].astro)
   → Renders the collaborative editor UI
   → Establishes WebSocket connection

2. WebSocket API (src/pages/api/doc/[id].ts)
   → Handles WebSocket upgrade requests
   → Connects to Document Durable Object

3. Document Durable Object (src/worker.ts)
   → Manages document state and history
   → Broadcasts changes to all connected clients
   → Tracks user cursors and selections
   → Persists document content
```

## Create a new Astro application

Let's get started by creating a new Astro project. Execute the following command:

```bash
npm create astro@latest my-astro-collab-editor
cd my-astro-collab-editor
npm install wrangler
```

When prompted, choose:

- `Use minimal (empty) template` when prompted on how to start the new project.
- `Yes` when prompted to install dependencies.
- `Yes` when prompted to initialize a git repository.

Once that's done, you can move into the project directory and start the app:

```bash
npm run dev
```

The app should be running on [localhost:4321](http://localhost:4321/).

## Integrate Cloudflare adapter in your Astro project

To deploy your Astro project to Cloudflare Workers and use Durable Objects, you need to install the Cloudflare adapter. Execute the command below:

```bash
npx astro add cloudflare --yes
```

Update `astro.config.mjs` to specify the worker entry point:

```javascript {6,8-11}
// File: astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',
  adapter: cloudflare({
    workerEntryPoint: {
      path: './src/worker.ts',
      namedExports: ['Document']
    }
  })
});
```

## Configure Durable Objects binding

Create `wrangler.jsonc` in the root of your project:

```jsonc {4-17}
// File: wrangler.jsonc
{
    // ...
    "durable_objects": {
        "bindings": [
            {
                "name": "Document",
                "class_name": "Document"
            }
        ]
    },
    "migrations": [
        {
            "tag": "v1",
            "new_sqlite_classes": ["Document"]
        }
    ]
}
```

## Add TypeScript types

Create `src/env.d.ts` to add proper types for the Durable Objects binding:

```typescript
// File: src/env.d.ts
/// <reference types="astro/client" />
/// <reference types="@cloudflare/workers-types" />

type ENV = {
  Document: DurableObjectNamespace
}

type DurableObjectNamespace = import('@cloudflare/workers-types').DurableObjectNamespace

type Runtime = import('@astrojs/cloudflare').Runtime<ENV>

declare namespace App {
  interface Locals extends Runtime { }
}
```

## Create the Document Durable Object

Build the Durable Object that manages document state and WebSocket connections:

```typescript collapse={0,66-255}
// File: src/worker.ts

import { DurableObject } from 'cloudflare:workers';
import { handle } from '@astrojs/cloudflare/handler';
import type { SSRManifest } from 'astro';
import { App } from 'astro/app';

interface SessionData {
  userId: string
  userName: string
  color: string
}

interface Version {
  content: string
  timestamp: number
  userId?: string
  userName?: string
}

interface Message {
  type: 'init' | 'change' | 'cursor' | 'user-join' | 'user-leave' | 'get-versions' | 'versions' | 'restore-version'
  content?: string
  userId?: string
  userName?: string
  color?: string
  position?: number
  changes?: { from: number; to: number; insert: string }
  users?: SessionData[]
  versions?: Version[]
  versionIndex?: number
}

export class Document extends DurableObject<ENV> {
  private state: DurableObjectState
  private sessions: Map<WebSocket, SessionData>
  private content: string
  private versions: Version[]

  constructor(ctx: DurableObjectState, env: ENV) {
    super(ctx, env)
    this.state = ctx
    this.sessions = new Map()
    this.content = ''
    this.versions = []

    // Load document content and versions from storage
    this.state.blockConcurrencyWhile(async () => {
      const stored = await this.state.storage.get<string>('content')
      this.content = stored || ''
      
      const storedVersions = await this.state.storage.get<Version[]>('versions')
      this.versions = storedVersions || []
      
      // If there's content but no versions, create initial version
      if (this.content && this.versions.length === 0) {
        this.versions.push({
          content: this.content,
          timestamp: Date.now()
        })
        await this.state.storage.put('versions', this.versions)
      }
    })
  }

  async fetch(request: Request): Promise<Response> {
    const upgradeHeader = request.headers.get('Upgrade')
    if (upgradeHeader !== 'websocket') {
      return new Response('Expected WebSocket', { status: 426 })
    }

    const pair = new WebSocketPair()
    const [client, server] = Object.values(pair)

    this.state.acceptWebSocket(server)

    return new Response(null, {
      status: 101,
      webSocket: client,
    })
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    try {
      const data = JSON.parse(message.toString()) as Message

      switch (data.type) {
        case 'init': {
          // New user connecting
          const sessionData: SessionData = {
            userId: data.userId!,
            userName: data.userName!,
            color: data.color!,
          }

          this.sessions.set(ws, sessionData)

          // Send current document state to new user
          ws.send(
            JSON.stringify({
              type: 'init',
              content: this.content,
              users: Array.from(this.sessions.values()),
            })
          )

          // Notify others about new user
          this.broadcast(
            {
              type: 'user-join',
              userId: sessionData.userId,
              userName: sessionData.userName,
              color: sessionData.color,
            },
            ws
          )
          break
        }

        case 'change': {
          // Apply and broadcast document changes
          const { changes } = data
          if (changes) {
            // Simple operational transform: apply change to content
            const before = this.content.slice(0, changes.from)
            const after = this.content.slice(changes.to)
            this.content = before + changes.insert + after

            // Persist to storage
            await this.state.storage.put('content', this.content)

            // Save version (limit to 50 versions)
            const session = this.sessions.get(ws)
            this.versions.push({
              content: this.content,
              timestamp: Date.now(),
              userId: session?.userId,
              userName: session?.userName
            })
            
            // Keep only last 50 versions
            if (this.versions.length > 50) {
              this.versions = this.versions.slice(-50)
            }
            
            await this.state.storage.put('versions', this.versions)

            // Broadcast to all clients
            this.broadcast(
              {
                type: 'change',
                changes,
                userId: this.sessions.get(ws)?.userId,
              },
              ws
            )
          }
          break
        }

        case 'get-versions': {
          // Send version history to requesting client
          ws.send(
            JSON.stringify({
              type: 'versions',
              versions: this.versions
            })
          )
          break
        }

        case 'restore-version': {
          // Restore to a specific version
          if (data.versionIndex !== undefined && data.versionIndex >= 0 && data.versionIndex < this.versions.length) {
            const version = this.versions[data.versionIndex]
            this.content = version.content
            
            await this.state.storage.put('content', this.content)
            
            // Broadcast full content update to all clients
            this.broadcast({
              type: 'init',
              content: this.content,
              users: Array.from(this.sessions.values())
            })
          }
          break
        }

        case 'cursor': {
          // Broadcast cursor position to others
          const session = this.sessions.get(ws)
          if (session) {
            this.broadcast(
              {
                type: 'cursor',
                userId: session.userId,
                position: data.position,
              },
              ws
            )
          }
          break
        }
      }
    } catch (error) {
      console.error('WebSocket message error:', error)
    }
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string) {
    const session = this.sessions.get(ws)
    if (session) {
      // Notify others about user leaving
      this.broadcast({
        type: 'user-leave',
        userId: session.userId,
      })
      this.sessions.delete(ws)
    }
    ws.close(code, reason)
  }

  async webSocketError(ws: WebSocket, error: unknown) {
    console.error('WebSocket error:', error)
    ws.close(1011, 'WebSocket error')
  }

  private broadcast(message: Message, exclude?: WebSocket) {
    const payload = JSON.stringify(message)
    for (const [ws, _] of this.sessions) {
      if (ws !== exclude) {
        try {
          ws.send(payload)
        } catch (error) {
          console.error('Broadcast error:', error)
        }
      }
    }
  }
}

export function createExports(manifest: SSRManifest) {
  const app = new App(manifest);
  return {
    default: {
      async fetch(request, env, ctx) {
        // @ts-expect-error - request is not typed correctly
        return handle(manifest, app, request, env, ctx);
      }
    } satisfies ExportedHandler<ENV>,
    Document,
  }
}
```

The code above shows the main implementation of a Cloudflare Durable Object class, `Document`, for a collaborative real-time text editor. Here’s a breakdown of what’s happening:

- **Session and User Management:** The system keeps track of connected users (sessions) using WebSocket connections. When a WebSocket closes or errors, the session is cleaned up, remaining clients are notified of the user leaving, and the connection is properly closed.

- **Broadcasting:**  The `broadcast` method is responsible for sending updates (such as document changes, user joins/leaves, version history responses, etc.) to all connected WebSocket clients, except optionally the one that triggered the event. This ensures that changes are synchronized across all users in real time.

- **Exporting the Handler and Document Durable Object:** The `createExports` function integrates the Astro SSR app with the Cloudflare Worker environment. It returns an object that includes the SSR handler (`default.fetch`) for HTTP requests, as well as exposing the `Document` class as a Durable Object, allowing the platform to route WebSocket upgrade requests and manage persistent state for each document.

Overall, this code wires up all server-side functionality needed for collaborative document editing: handling real-time messaging, managing persistent document state, tracking version history, and enabling Astro’s SSR routes to work alongside live collaboration features.

## Create the WebSocket API endpoint

Build the API route that connects clients to the Document Durable Object:

```typescript
// File: src/pages/api/doc/[id].ts

import type { APIRoute } from 'astro'

export const GET: APIRoute = async ({ params, locals, request }) => {
  const { id } = params
  const upgradeHeader = request.headers.get('Upgrade')
  if (upgradeHeader !== 'websocket')
    return new Response('Expected WebSocket', { status: 426 })
  // Get the Durable Object for this document
  const documentId = locals.runtime.env.Document.idFromName(id!)
  const documentStub = locals.runtime.env.Document.get(documentId)
  // Forward the WebSocket upgrade to the Durable Object
  return documentStub.fetch(request)
}
```

The code above defines an API route in Astro at `src/pages/api/doc/[id].ts` that acts as the entry point for WebSocket clients wishing to join a collaborative document session. Here's how it works step-by-step:

- When a GET request is made to this endpoint (usually by a client attempting to join or create a collaborative editing session), the code first checks if the request is a WebSocket upgrade. If it's not, it responds with a 426 status to indicate that a WebSocket connection is required.

- If the request is a valid WebSocket upgrade, the endpoint uses the provided `id` parameter from the URL to identify the collaborative document (and thus, its associated Durable Object instance).

- It retrieves the Durable Object ID by calling `idFromName` on the Durable Object namespace, using the document's `id`. Then, it gets a stub (a remote handle) to the correct Durable Object instance using `.get`.

- Finally, the code forwards the entire incoming request (including the WebSocket upgrade) directly to the Durable Object's `fetch` method. This hands off the connection (and its upgrade) to the Durable Object, which manages the document's shared state and synchronizes edits in real time between all connected clients.

## Build the collaborative editor UI

Create the editor page with real-time synchronization:

```astro collapse={0,20-472}
---
const { id } = Astro.params;
---

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Collaborative Editor - {id}</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        font-family: 'Inter', system-ui, sans-serif;
        background: #f8fafc;
        color: #0f172a;
      }
      .container {
        max-width: 900px;
        margin: 0 auto;
        padding: 24px;
      }
      .header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 24px;
        padding: 16px;
        background: white;
        border-radius: 8px;
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
      }
      .users {
        display: flex;
        gap: 8px;
        align-items: center;
      }
      .user-badge {
        padding: 6px 12px;
        border-radius: 16px;
        font-size: 14px;
        font-weight: 500;
        color: white;
      }
      .status {
        font-size: 14px;
        color: #64748b;
      }
      .status.connected {
        color: #10b981;
      }
      .editor-wrapper {
        background: white;
        border-radius: 8px;
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
        padding: 24px;
      }
      #editor {
        width: 100%;
        min-height: 500px;
        border: 1px solid #e2e8f0;
        border-radius: 6px;
        padding: 16px;
        font-family: 'Monaco', 'Courier New', monospace;
        font-size: 16px;
        line-height: 1.6;
        resize: vertical;
      }
      #editor:focus {
        outline: none;
        border-color: #3b82f6;
      }
      .cursors {
        position: relative;
      }
      .cursor {
        position: absolute;
        width: 2px;
        height: 20px;
        pointer-events: none;
        transition: all 0.1s ease;
      }
      .cursor-label {
        position: absolute;
        top: -24px;
        left: -4px;
        padding: 2px 8px;
        border-radius: 4px;
        font-size: 12px;
        color: white;
        white-space: nowrap;
      }
      .version-btn {
        padding: 10px 20px;
        background: #3b82f6;
        color: white;
        border: none;
        border-radius: 6px;
        font-size: 14px;
        font-weight: 500;
        cursor: pointer;
        transition: background 0.2s;
      }
      .version-btn:hover {
        background: #2563eb;
      }
      .modal {
        display: none;
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.5);
        z-index: 1000;
        align-items: center;
        justify-content: center;
      }
      .modal.open {
        display: flex;
      }
      .modal-content {
        background: white;
        border-radius: 12px;
        padding: 24px;
        max-width: 600px;
        width: 90%;
        max-height: 80vh;
        overflow-y: auto;
        box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
      }
      .modal-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 20px;
      }
      .modal-header h2 {
        font-size: 20px;
        font-weight: 600;
      }
      .close-btn {
        background: none;
        border: none;
        font-size: 24px;
        cursor: pointer;
        color: #64748b;
        padding: 0;
        width: 32px;
        height: 32px;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 4px;
      }
      .close-btn:hover {
        background: #f1f5f9;
        color: #0f172a;
      }
      .version-list {
        display: flex;
        flex-direction: column;
        gap: 12px;
      }
      .version-item {
        padding: 16px;
        border: 1px solid #e2e8f0;
        border-radius: 8px;
        cursor: pointer;
        transition: all 0.2s;
      }
      .version-item:hover {
        border-color: #3b82f6;
        background: #f8fafc;
      }
      .version-meta {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 8px;
      }
      .version-time {
        font-size: 14px;
        color: #64748b;
      }
      .version-user {
        font-size: 12px;
        padding: 4px 8px;
        background: #f1f5f9;
        border-radius: 4px;
        color: #475569;
      }
      .version-preview {
        font-size: 13px;
        color: #475569;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        font-family: 'Monaco', 'Courier New', monospace;
      }
      .empty-state {
        text-align: center;
        padding: 40px 20px;
        color: #64748b;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <div class="header">
        <div>
          <h1>Document: {id}</h1>
          <div id="status" class="status">Connecting...</div>
        </div>
        <div style="display: flex; gap: 16px; align-items: center;">
          <button id="versionBtn" class="version-btn">View Versions</button>
          <div class="users" id="users"></div>
        </div>
      </div>

      <div class="editor-wrapper">
        <textarea id="editor" placeholder="Start typing..."></textarea>
      </div>
    </div>

    <div id="versionModal" class="modal">
      <div class="modal-content">
        <div class="modal-header">
          <h2>Version History</h2>
          <button id="closeModal" class="close-btn">&times;</button>
        </div>
        <div id="versionList" class="version-list"></div>
      </div>
    </div>

    <script define:vars={{ documentId: id }}>
      const editor = document.getElementById('editor')
      const status = document.getElementById('status')
      const usersContainer = document.getElementById('users')

      // Generate random user info
      const userId = Math.random().toString(36).substring(7)
      const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6']
      const userColor = colors[Math.floor(Math.random() * colors.length)]
      const userName = `User ${userId.substring(0, 4)}`

      let ws = null
      let users = new Map()
      let isRemoteUpdate = false
      let versions = []

      function connect() {
        const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
        ws = new WebSocket(`${protocol}//${window.location.host}/api/doc/${documentId}`)

        ws.onopen = () => {
          status.textContent = 'Connected'
          status.classList.add('connected')

          // Send init message
          ws.send(JSON.stringify({
            type: 'init',
            userId,
            userName,
            color: userColor,
          }))
        }

        ws.onmessage = (event) => {
          const data = JSON.parse(event.data)

          switch (data.type) {
            case 'init': {
              // Set initial content
              isRemoteUpdate = true
              editor.value = data.content
              lastValue = data.content
              isRemoteUpdate = false

              // Update users list
              data.users.forEach((user) => {
                if (user.userId !== userId) {
                  users.set(user.userId, user)
                }
              })
              updateUsersList()
              break
            }

            case 'change': {
              // Apply remote changes
              if (data.userId !== userId) {
                isRemoteUpdate = true
                const { from, to, insert } = data.changes
                const before = editor.value.slice(0, from)
                const after = editor.value.slice(to)
                editor.value = before + insert + after
                lastValue = editor.value
                isRemoteUpdate = false
              }
              break
            }

            case 'user-join': {
              users.set(data.userId, {
                userId: data.userId,
                userName: data.userName,
                color: data.color,
              })
              updateUsersList()
              break
            }

            case 'user-leave': {
              users.delete(data.userId)
              updateUsersList()
              break
            }

            case 'cursor': {
              // Handle cursor updates from other users
              console.log('Cursor update:', data)
              break
            }

            case 'versions': {
              // Received version history
              versions = data.versions || []
              displayVersions()
              break
            }
          }
        }

        ws.onclose = () => {
          status.textContent = 'Disconnected'
          status.classList.remove('connected')
          setTimeout(connect, 2000)
        }

        ws.onerror = (error) => {
          console.error('WebSocket error:', error)
        }
      }

      function updateUsersList() {
        usersContainer.innerHTML = ''
        users.forEach((user) => {
          const badge = document.createElement('div')
          badge.className = 'user-badge'
          badge.style.backgroundColor = user.color
          badge.textContent = user.userName
          usersContainer.appendChild(badge)
        })
      }

      // Track changes
      let lastValue = ''
      editor.addEventListener('input', () => {
        if (isRemoteUpdate || !ws || ws.readyState !== WebSocket.OPEN) {
          return
        }

        const newValue = editor.value
        const cursorPos = editor.selectionStart

        // Simple diff: find where the change occurred
        let from = 0
        while (from < lastValue.length && from < newValue.length && lastValue[from] === newValue[from]) {
          from++
        }

        let lastEnd = lastValue.length
        let newEnd = newValue.length
        while (lastEnd > from && newEnd > from && lastValue[lastEnd - 1] === newValue[newEnd - 1]) {
          lastEnd--
          newEnd--
        }

        const insert = newValue.slice(from, newEnd)

        // Send change
        ws.send(JSON.stringify({
          type: 'change',
          changes: { from, to: lastEnd, insert },
        }))

        lastValue = newValue
      })

      // Track cursor position
      editor.addEventListener('selectionchange', () => {
        if (ws && ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify({
            type: 'cursor',
            position: editor.selectionStart,
          }))
        }
      })

      // Version modal handling
      const versionBtn = document.getElementById('versionBtn')
      const versionModal = document.getElementById('versionModal')
      const closeModal = document.getElementById('closeModal')
      const versionList = document.getElementById('versionList')

      versionBtn.addEventListener('click', () => {
        if (ws && ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify({ type: 'get-versions' }))
          versionModal.classList.add('open')
        }
      })

      closeModal.addEventListener('click', () => {
        versionModal.classList.remove('open')
      })

      versionModal.addEventListener('click', (e) => {
        if (e.target === versionModal) {
          versionModal.classList.remove('open')
        }
      })

      function displayVersions() {
        if (versions.length === 0) {
          versionList.innerHTML = '<div class="empty-state">No versions yet</div>'
          return
        }

        versionList.innerHTML = versions
          .map((version, index) => {
            const date = new Date(version.timestamp)
            const timeStr = date.toLocaleString()
            const preview = version.content.substring(0, 100).replace(/\n/g, ' ')
            const userInfo = version.userName ? `by ${version.userName}` : ''

            return `
              <div class="version-item" data-index="${index}">
                <div class="version-meta">
                  <div class="version-time">${timeStr}</div>
                  ${userInfo ? `<div class="version-user">${userInfo}</div>` : ''}
                </div>
                <div class="version-preview">${preview || '(empty)'}</div>
              </div>
            `
          })
          .reverse()
          .join('')

        // Add click handlers to version items
        document.querySelectorAll('.version-item').forEach((item) => {
          item.addEventListener('click', () => {
            const index = parseInt(item.dataset.index)
            if (ws && ws.readyState === WebSocket.OPEN) {
              ws.send(JSON.stringify({
                type: 'restore-version',
                versionIndex: index
              }))
              versionModal.classList.remove('open')
            }
          })
        })
      }

      connect()
    </script>
  </body>
</html>
```

The code above implements version history tracking for the collaborative text editor using a Cloudflare Durable Object (`Document`). Here’s what it does:

- **Version Interface**: It defines a `Version` object to represent a snapshot of the document. Each version keeps a `timestamp`, the `content` of the document at that moment, and the `userId` of the user who made the change.

- **Initialization**: In the `Document` constructor, it loads both the current document content and all previous versions from persistent storage inside `blockConcurrencyWhile`. This ensures the latest data is available before processing any requests.

- **On Each Change**: Inside the `'change'` handler (which processes edits from users):
    - It applies the change operation to the document content.
    - It creates a new `Version` object, capturing the new document state, the current timestamp, and the editor's user ID, and pushes it to the `versions` array.
    - To prevent unbounded storage growth, it only keeps the latest 100 versions.
    - Both the new content and the updated version history are saved persistently with `this.state.storage.put`.

- **Broadcasting**: After updating content and versions, it notifies all connected clients (except the one that sent the change) of the update using `this.broadcast`.

This mechanism enables features like undo/history, lets users view and restore previous versions in the editor UI, and makes collaborative editing auditable and resilient to accidental data loss.

## Handle version history

Add version history tracking to the Durable Object:

```typescript
// Add to Document class in src/worker.ts

interface Version {
  timestamp: number
  content: string
  userId: string
}

// In constructor:
private versions: Version[] = []

this.state.blockConcurrencyWhile(async () => {
  const stored = await this.state.storage.get<string>('content')
  this.content = stored || ''
  
  const storedVersions = await this.state.storage.get<Version[]>('versions')
  this.versions = storedVersions || []
})

// In 'change' handler:
case 'change': {
  const { changes } = data
  if (changes) {
    const before = this.content.slice(0, changes.from)
    const after = this.content.slice(changes.to)
    this.content = before + changes.insert + after

    // Save version
    this.versions.push({
      timestamp: Date.now(),
      content: this.content,
      userId: this.sessions.get(ws)?.userId || 'unknown',
    })

    // Keep last 100 versions
    if (this.versions.length > 100) {
      this.versions = this.versions.slice(-100)
    }

    await this.state.storage.put('content', this.content)
    await this.state.storage.put('versions', this.versions)

    this.broadcast({ type: 'change', changes, userId: this.sessions.get(ws)?.userId }, ws)
  }
  break
}
```

## Deploy to Cloudflare Workers

Deploy your collaborative editor to production:

```bash
# Build the project
npm run build

# Deploy to Cloudflare Workers
npx wrangler deploy
```

Your collaborative editor is now live and running on Cloudflare's global network.

## Conclusion

Cloudflare Durable Objects make collaborative editing accessible without complex operational transform servers or CRDT libraries. By leveraging strong consistency and built-in WebSocket support, you can build real-time collaboration features that scale globally with minimal infrastructure.

The pattern you've built, document state + change broadcasting + cursor tracking, scales to any collaborative use case. Add it to note-taking apps, code editors, design tools, or any application that needs real-time multi-user editing.

If you have any questions or comments, feel free to reach out to me on [Twitter](https://twitter.com/direct_messages/create/rishi_raj_jain_).
