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 are all complex to set up and expensive to scale.
Cloudflare 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.
Demo
Prerequisites
You’ll need:
- Node.js 20 or later
- A Cloudflare 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 contentCreate a new Astro application
Let’s get started by creating a new Astro project. Execute the following command:
npm create astro@latest my-astro-collab-editorcd my-astro-collab-editornpm install wranglerWhen prompted, choose:
Use minimal (empty) templatewhen prompted on how to start the new project.Yeswhen prompted to install dependencies.Yeswhen prompted to initialize a git repository.
Once that’s done, you can move into the project directory and start the app:
npm run devThe app should be running on 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:
npx astro add cloudflare --yesUpdate astro.config.mjs to specify the worker entry point:
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:
{ // ... "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:
/// <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:
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) } }) }
189 collapsed lines
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
broadcastmethod 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
createExportsfunction 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 theDocumentclass 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:
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
idparameter from the URL to identify the collaborative document (and thus, its associated Durable Object instance). -
It retrieves the Durable Object ID by calling
idFromNameon the Durable Object namespace, using the document’sid. 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
fetchmethod. 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:
---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;452 collapsed lines
} .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">×</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
Versionobject to represent a snapshot of the document. Each version keeps atimestamp, thecontentof the document at that moment, and theuserIdof the user who made the change. -
Initialization: In the
Documentconstructor, it loads both the current document content and all previous versions from persistent storage insideblockConcurrencyWhile. 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
Versionobject, capturing the new document state, the current timestamp, and the editor’s user ID, and pushes it to theversionsarray. - 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:
// 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:
# Build the projectnpm run build
# Deploy to Cloudflare Workersnpx wrangler deployYour 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, i.e. 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.