Live Collaborative Editing in Astro with Cloudflare Durable Objects
LaunchFast Logo LaunchFast

Live Collaborative Editing in Astro with Cloudflare Durable Objects

Rishi Raj Jain
Live Collaborative Editing in Astro with Cloudflare Durable Objects

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.

High Quality Starter Kits with built-in authentication flow (Auth.js), object uploads (AWS, Clouflare R2, Firebase Storage, Supabase Storage), integrated payments (Stripe, LemonSqueezy), email verification flow (Resend, Postmark, Sendgrid), and much more. Compatible with any database (Redis, Postgres, MongoDB, SQLite, Firestore).
Next.js Starter Kit
SvelteKit Starter Kit

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:

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:

Terminal window
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:

Terminal window
npm run dev

The 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:

Terminal window
npx astro add cloudflare --yes

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

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:

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:

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:

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)
}
})
}
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 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:

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:

---
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">&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:

// 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:

Terminal window
# 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, 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.

Learn More Bot Protection in Astro with Cloudflare Turnstile
Bot Protection in Astro with Cloudflare Turnstile December 9, 2025
Generating PDFs in Astro with Cloudflare Browser Rendering at the Edge
Generating PDFs in Astro with Cloudflare Browser Rendering at the Edge December 8, 2025
Implement Rate Limiting in Astro with Cloudflare Workers
Implement Rate Limiting in Astro with Cloudflare Workers December 6, 2025