Tekko

Language

Get in Touch

Usually respond within 24 hours

Back to BlogArchitecture

Scaling Real-Time Apps: Serverless WebSockets with Durable Objects

8 min read
CloudflareServerlessWebSocketsTypeScriptBackend
Scaling Real-Time Apps: Serverless WebSockets with Durable Objects

Building real-time, collaborative applications has historically been a challenge in serverless environments. If you’ve ever tried to build a live chat, a collaborative document editor, or a multiplayer game using standard serverless functions (like AWS Lambda or standard Cloudflare Workers), you’ve likely hit the 'stateless wall.'

Serverless functions are designed to be short-lived and independent. They don't share memory. To synchronize state between two users connected to different function instances, you’re forced to introduce a middleman—usually a managed Redis instance for Pub/Sub or a heavy database layer. This adds latency, cost, and architectural complexity.

Cloudflare Durable Objects (DO) change this paradigm. They provide a way to maintain persistent state and handle WebSocket connections directly within the serverless edge, effectively eliminating the need for an external state provider like Redis for many use cases.

The Problem with Stateless WebSockets

In a traditional serverless setup, when a client opens a WebSocket connection, it hits a load balancer and is routed to an available execution environment. If User A and User B are collaborating on the same document, their WebSockets might land on two entirely different servers in different data centers.

To make User A’s cursor move on User B’s screen, you have to:

  1. Capture the event in User A's serverless function.
  2. Push that event to a global backend (Redis).
  3. Have User B's serverless function subscribe to that Redis channel.
  4. Push the update down User B's WebSocket.

This 'triple-hop' increases the Round Trip Time (RTT) and introduces a single point of failure (the Redis cluster) that you now have to manage, scale, and secure.

Understanding the Durable Object Model

Cloudflare Durable Objects offer a different approach: Location-transparent, single-instance state.

A Durable Object is a class-based piece of code that combines compute (like a Worker) with persistent storage. The 'Durable' part means that for a given ID (which you define), only one instance of that object exists globally.

If 100 users all connect to a Durable Object named document-123, Cloudflare ensures they are all routed to the exact same instance of that object. Because they are all in the same execution context, they share the same memory. You no longer need Redis to synchronize them; you can simply maintain a list of active WebSockets in a class variable and broadcast messages to them directly.

Architectural Shift: Removing the Middleman

By moving the coordination logic into a Durable Object, your architecture simplifies significantly.

Traditional Approach: Client <-> Worker <-> Redis Pub/Sub <-> Worker <-> Client

Durable Object Approach: Client <-> Durable Object (State + Logic) <-> Client

This shift reduces the infrastructure footprint and keeps the logic closer to the edge. Since the Durable Object lives at the edge (Cloudflare's network), the latency for the initial WebSocket handshake is minimal, and the subsequent message passing happens in-memory.

Implementing a Collaborative State Manager

Let’s look at how to implement a basic collaborative state manager. In this example, we’ll create a system where multiple clients can sync a shared JSON object.

1. Defining the Durable Object Class

First, we define our class. This class will handle the WebSocket connections and manage the shared state.

export class CollaborativeRoom { state: DurableObjectState; sessions: WebSocket[] = []; sharedData: Record<string, any> = {}; constructor(state: DurableObjectState) { this.state = state; // Restore state from permanent storage on startup this.state.blockConcurrencyWhile(async () => { let stored = await this.state.storage.get<Record<string, any>>("data"); this.sharedData = stored || {}; }); } async fetch(request: Request) { const upgradeHeader = request.headers.get("Upgrade"); if (!upgradeHeader || upgradeHeader !== "websocket") { return new Response("Expected Upgrade: websocket", { status: 426 }); } const [client, server] = Object.values(new WebSocketPair()); await this.handleSession(server); return new Response(null, { status: 101, webSocket: client }); } async handleSession(ws: WebSocket) { ws.accept(); this.sessions.push(ws); // Send initial state to the new client ws.send(JSON.stringify({ type: "INIT", data: this.sharedData })); ws.addEventListener("message", async (msg) => { try { const payload = JSON.parse(msg.data as string); // Update local state this.sharedData = { ...this.sharedData, ...payload.delta }; // Persist to disk (non-blocking for the broadcast) this.state.storage.put("data", this.sharedData); // Broadcast to everyone else this.broadcast(JSON.stringify({ type: "UPDATE", delta: payload.delta }), ws); } catch (err) { ws.send(JSON.stringify({ error: "Invalid payload" })); } }); ws.addEventListener("close", () => { this.sessions = this.sessions.filter(s => s !== ws); }); } broadcast(message: string, sender: WebSocket) { for (const session of this.sessions) { if (session !== sender) { session.send(message); } } } }

2. Routing Requests from the Worker

Your main Worker acts as the entry point, determining which Durable Object instance should handle the request based on a URL parameter or header.

export default { async fetch(request: Request, env: Env) { const url = new URL(request.url); const roomId = url.searchParams.get("room") || "default"; // Get the ID for the named object const id = env.ROOMS.idFromName(roomId); // Get the stub (a client for the DO) const roomStub = env.ROOMS.get(id); // Forward the request to the DO return roomStub.fetch(request); } };

Optimizing with the WebSocket Hibernation API

The implementation above is straightforward, but it has a cost implication. In the standard model, the Durable Object must stay 'awake' (active in memory) as long as a WebSocket is connected. This can lead to higher costs if you have many idle connections.

Cloudflare introduced the WebSocket Hibernation API to solve this. It allows the Durable Object to serialize its state and shut down even while WebSockets are connected. When a message arrives on any of those WebSockets, Cloudflare 'wakes up' the object, injects the message, and allows it to process the event.

To use Hibernation, you replace ws.addEventListener with state.acceptWebSocket(ws). The messages are then handled by a specific method in the class: webSocketMessage(ws, message).

Consistency vs. Latency: The Location Factor

One critical thing to understand about Durable Objects is their geographic location. Because a Durable Object is a single instance, it lives in one data center.

If the first person to create room-A is in London, Cloudflare will likely spin up the DO in a Western Europe data center. If a user from Tokyo then joins that same room, their WebSocket traffic must travel to London and back.

While this sounds like a drawback, it is actually a fundamental trade-off for strong consistency. You cannot have a single source of truth for a collaborative state without a single point of coordination. Compared to a centralized Redis instance in us-east-1, Durable Objects are still superior because they are dynamically instantiated closer to the initial cluster of users.

When to Use This Architecture

While Durable Objects are powerful, they aren't a silver bullet for every real-time requirement.

Ideal Use Cases:

  • Collaborative Tools: Whiteboards, document editors (Google Docs style), and project management boards.
  • Stateful Game Servers: Handling game state for a specific match or room.
  • Chat Systems: Managing presence and message delivery for specific channels.
  • Live Dashboards: Aggregating and pushing real-time telemetry to a group of users.

When to Reconsider:

  • Massive Broadcasts: If you need to push a single message to 1 million users simultaneously (e.g., a live sports score), a Pub/Sub model with a CDN-based fan-out (like Ably or Pusher) is more efficient.
  • Simple Pub/Sub: If you don't actually need to store state and just need to pass messages, Cloudflare's Pub/Sub product or Workers for Platforms might be more cost-effective.

Practical Implementation Tips

  1. Use CRDTs: For complex collaborative editing, don't just overwrite JSON. Use Conflict-free Replicated Data Types (like Yjs or Automerge). These libraries work beautifully inside Durable Objects.
  2. Handle Reconnections: WebSockets drop. Ensure your client-side code handles reconnections gracefully and that your Durable Object can reconcile state when a user returns.
  3. Storage Limits: Durable Object storage is great for state, but it’s not a replacement for a data warehouse. Keep your sharedData objects lean.
  4. Security: Always validate the WebSocket handshake. Use JWTs or session tokens in the request headers before calling ws.accept().

Conclusion

Cloudflare Durable Objects represent a significant evolution in serverless architecture. By providing a built-in mechanism for state and connectivity, they remove the friction of managing external infrastructure for real-time apps.

If you're building a collaborative application today, start by defining your state boundaries. If your users naturally group into 'rooms,' 'documents,' or 'projects,' you have a perfect candidate for Durable Objects. You'll end up with a simpler codebase, lower latency, and a much more scalable solution than traditional managed Redis setups.

Actionable next step: Port a small part of your real-time logic—perhaps a simple 'user presence' feature—to a Durable Object. You'll likely find that the reduction in 'glue code' alone makes the switch worthwhile.