Illustration of real-time data flow

Implementing Real-Time Features with Cloudflare Durable Objects

When WebSockets Feel Like Overkill

Last week, I found myself sketching out a small demo project: a collaborative map where two people could share their locations and see the best route to meet up. Nothing fancy, just a proof-of-concept to show off at a local meetup.

My first instinct was to reach for the familiar—a Node.js backend with WebSockets. But something felt wrong about spinning up a whole server for such a simple feature. “There’s got to be an easier way,” I thought during my third cup of coffee.

That’s when I remembered something I’d been wanting to try for ages: Cloudflare Durable Objects. I’d heard they were great for stateful applications at the edge, and this seemed like the perfect excuse to finally dive in.

What Are Durable Objects Anyway?

If you’ve never heard of Durable Objects before, think of them as little stateful applications that live on Cloudflare’s edge. Unlike regular serverless functions that start fresh with every request, Durable Objects maintain their state between calls.

What made them click for me was realizing they’re like tiny servers that:

  • Keep data in memory (great for real-time stuff)
  • Handle WebSockets natively (no extra services needed)
  • Run close to your users (thanks to Cloudflare’s global network)
  • Scale automatically (one less thing to worry about)

The perfect building block for a simple real-time demo!

Building a Simple Collaborative Map

For my demo, I wanted to create something really straightforward: a map where people could drop pins and immediately see each other’s locations. Here’s how I built it with Durable Objects.

The Durable Object

Let me show you the actual code I’m using for the demo—it’s remarkably simple:

export class WaypointMap extends DurableObject<Env> {
	private pinLocation: { lat: number; lng: number } | null = null
 
	/**
	 * Handles HTTP requests, particularly WebSocket upgrade requests
	 */
	async fetch(request: Request): Promise<Response> {
		// Create a WebSocket pair
		const webSocketPair = new WebSocketPair()
		const [client, server] = Object.values(webSocketPair)
 
		// Use acceptWebSocket to enable hibernation
		this.ctx.acceptWebSocket(server)
 
		// Send the current pin location to the new client if one exists
		if (this.pinLocation) {
			server.send(
				JSON.stringify({
					type: 'pinUpdate',
					location: this.pinLocation,
				})
			)
		}
 
		return new Response(null, {
			status: 101,
			webSocket: client,
		})
	}
 
	/**
	 * Handles incoming WebSocket messages
	 */
	async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
		try {
			const data = JSON.parse(message)
 
			// Handle ping messages with a pong response for connection stability
			if (data.type === 'ping') {
				ws.send(
					JSON.stringify({
						type: 'pong',
						id: data.id || '',
						clientTime: data.clientTime || 0,
					})
				)
				return
			}
 
			if (data.type === 'setPin' && data.location) {
				this.pinLocation = data.location
 
				// Broadcast the pin update to all connected clients
				this.broadcast({
					type: 'pinUpdate',
					location: this.pinLocation,
				})
			}
		} catch (error) {
			// Silent error handling for demo
		}
	}
 
	/**
	 * Broadcasts a message to all connected WebSockets
	 */
	private broadcast(message: Record<string, any>): void {
		const messageString = JSON.stringify(message)
		for (const ws of this.ctx.getWebSockets()) {
			ws.send(messageString)
		}
	}
}

What blew me away was how little code it took to get real-time functionality working. The entire Durable Object is less than 70 lines of code, and it handles:

  1. WebSocket connections with automatic hibernation support
  2. State management (the pin location)
  3. Broadcasting updates to all connected clients
  4. Connection health checks with ping/pong

The Worker Setup

To route requests to our Durable Object, we need a Worker that handles the initial connection:

export interface Env {
	WAYPOINT_MAP: DurableObjectNamespace
}
 
export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url)
 
		// Handle map room connections
		if (url.pathname === '/api/map') {
			// Get or create a map ID
			const mapId = url.searchParams.get('id') || 'default-map'
 
			// Get Durable Object stub
			const id = env.WAYPOINT_MAP.idFromName(mapId)
			const waypointMap = env.WAYPOINT_MAP.get(id)
 
			// Forward the request to the Durable Object
			return waypointMap.fetch(request)
		}
 
		return new Response('Not found', { status: 404 })
	},
}
 
// Export our Durable Object class
export { WaypointMap } from './waypoint'

Configuring with wrangler.jsonc

Setting this up in wrangler.jsonc is straightforward:

{
	"name": "waypoint-map",
	"main": "src/index.ts",
	"compatibility_date": "2023-12-01",
	"durable_objects": {
		"bindings": [
			{
				"name": "WAYPOINT_MAP",
				"class_name": "WaypointMap"
			}
		]
	},
	"migrations": [
		{
			"tag": "v1",
			"new_classes": ["WaypointMap"]
		}
	]
}

The Frontend Code

On the client side, I used MapLibre to display the map and connect to our Durable Object:

import maplibregl from 'maplibre-gl'
 
class WaypointClient {
	private map: maplibregl.Map
	private socket: WebSocket | null = null
	private marker: maplibregl.Marker | null = null
 
	constructor(element: HTMLElement) {
		// Initialize MapLibre
		this.map = new maplibregl.Map({
			container: element,
			style: 'https://demotiles.maplibre.org/style.json',
			center: [-74.5, 40],
			zoom: 9,
		})
 
		// Connect to our Durable Object
		this.connect()
 
		// Set up map click handler
		this.map.on('click', (e) => this.handleMapClick(e))
 
		// Set up ping interval to keep connection alive
		setInterval(() => this.sendPing(), 30000)
	}
 
	private connect(): void {
		const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
		this.socket = new WebSocket(`${protocol}//${window.location.host}/api/map?id=default-map`)
 
		this.socket.addEventListener('open', () => {
			console.log('Connected to waypoint map!')
		})
 
		this.socket.addEventListener('message', (event) => {
			this.handleMessage(event.data)
		})
 
		this.socket.addEventListener('close', () => {
			console.log('Connection closed, reconnecting...')
			setTimeout(() => this.connect(), 2000)
		})
	}
 
	private handleMapClick(e: maplibregl.MapMouseEvent): void {
		if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return
 
		// Send new pin location to Durable Object
		this.socket.send(
			JSON.stringify({
				type: 'setPin',
				location: {
					lat: e.lngLat.lat,
					lng: e.lngLat.lng,
				},
			})
		)
	}
 
	private handleMessage(data: string): void {
		try {
			const message = JSON.parse(data)
 
			if (message.type === 'pinUpdate' && message.location) {
				this.updateMarker(message.location)
			}
		} catch (err) {
			console.error('Failed to parse message', err)
		}
	}
 
	private updateMarker(location: { lat: number; lng: number }): void {
		// Remove existing marker if any
		if (this.marker) {
			this.marker.remove()
		}
 
		// Create a new marker element
		const el = document.createElement('div')
		el.className = 'waypoint-marker'
		el.style.width = '25px'
		el.style.height = '25px'
		el.style.borderRadius = '50%'
		el.style.backgroundColor = '#ff4136'
		el.style.border = '3px solid white'
		el.style.boxShadow = '0 0 10px rgba(0,0,0,0.3)'
 
		// Add marker to map
		this.marker = new maplibregl.Marker(el).setLngLat([location.lng, location.lat]).addTo(this.map)
 
		// Center the map on the marker
		this.map.flyTo({
			center: [location.lng, location.lat],
			zoom: 14,
		})
	}
 
	private sendPing(): void {
		if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return
 
		this.socket.send(
			JSON.stringify({
				type: 'ping',
				id: crypto.randomUUID(),
				clientTime: Date.now(),
			})
		)
	}
}
 
// Initialize when the page loads
document.addEventListener('DOMContentLoaded', () => {
	const mapElement = document.getElementById('map')
	if (mapElement) new WaypointClient(mapElement)
})

What I Learned Along the Way

This demo project taught me a lot about Durable Objects, and I was genuinely surprised by how easy it was to get started.

The Good Parts

  • Simplicity: The API is incredibly clean—just extend DurableObject and implement a few methods.
  • WebSocket handling: The platform handles all the complex WebSocket management for you.
  • Hibernation: Durable Objects automatically hibernate when inactive and wake up when needed.
  • Scalability: I don’t need to worry about scaling—Cloudflare handles that automatically.

The Challenges

  • Documentation: While improving, I had to dig through examples to understand some patterns.
  • Debugging: Remote debugging is a bit trickier than local development.
  • Mental model: It took me a bit to get used to thinking in terms of distributing objects rather than services.

When Durable Objects Make Sense

After building this demo, I realized Durable Objects are perfect for:

  • Real-time collaborative features
  • Simple multiplayer games or interactive experiences
  • Chatrooms and messaging features
  • Any feature that needs shared state with low latency

They’re probably not the best fit for heavy data processing or complex database operations.

What’s Coming Next

This simple demo is just the beginning. I’m working on extending it to create a more complete collaborative map experience where people can:

  • Drop multiple pins with different colors
  • Draw routes between locations
  • Share custom markers with metadata
  • Save and restore map sessions

All of this will be coming soon in a multiplayer map demo using MapLibre—stay tuned!

If you’re looking to add real-time features to your app without the complexity of setting up dedicated WebSocket servers, definitely give Durable Objects a try. I’ve been pleasantly surprised by how much I could accomplish with so little code.

Have you experimented with edge computing for real-time applications? I’m still learning this stuff myself and would love to hear your experiences!