Skip to content
Edge-Powered Real-time: Serverless WebSockets with Cloudflare & Durable Objects
Modern Tech & Innovations

Edge-Powered Real-time: Serverless WebSockets with Cloudflare & Durable Objects

8 min read
Cloudflare WorkersDurable ObjectsWebSocketsServerlessReal-time

Building highly scalable real-time applications often incurs significant infrastructure complexity and cost. This post demonstrates leveraging Cloudflare Workers and Durable Objects to build robust, cost-effective serverless WebSockets, drastically simplifying real-time feature development and global deployment.

1. Introduction & The Problem

In today's interconnected digital landscape, real-time functionality is no longer a luxury but a fundamental expectation. From collaborative document editing and live chat to financial dashboards and multiplayer games, users demand immediate updates and seamless interactivity. However, implementing real-time features, particularly at scale, presents a formidable challenge for developers and businesses alike. The traditional approach often involves provisioning and managing dedicated WebSocket servers, which come with a litany of pain points:

  • Scalability Nightmares: Scaling stateful WebSocket servers across multiple instances and regions is inherently complex, requiring intricate load balancing, session stickiness, and global state synchronization.
  • High Operational Overhead: Managing server uptime, patching, security, and infrastructure for WebSocket servers demands significant engineering resources and expertise.
  • Cost Inefficiency: Dedicated servers often incur substantial costs, especially when provisioning for peak loads, leading to underutilized resources during off-peak hours. Idle connections still consume resources.
  • Latency for Global Users: Centralized WebSocket servers introduce latency for users geographically distant from the server, degrading the real-time experience.
  • Cold Starts & Connection Management: Traditional serverless functions are not well-suited for long-lived WebSocket connections due to their stateless, ephemeral nature, leading to complex workarounds.

These challenges can lead to poor user experience, increased infrastructure bills, slower development cycles, and a substantial drain on engineering teams.

2. The Solution Concept & Architecture

The solution lies in embracing a truly serverless, edge-first approach using Cloudflare Workers and Durable Objects. This combination offers a powerful paradigm for building globally distributed, highly scalable, and cost-efficient real-time applications.

  • Cloudflare Workers: These are JavaScript/TypeScript functions that run on Cloudflare's global network, executing code at the edge – physically close to your users. Workers are ideal for handling initial WebSocket handshake requests and acting as a lightweight proxy. Their low latency and massive global distribution make them perfect for edge-terminating WebSocket connections.
  • Cloudflare Durable Objects: These are a groundbreaking primitive that provide globally consistent, single-instance state at the edge. Each Durable Object instance is a unique, long-lived JavaScript class instance that can maintain state and directly handle WebSocket connections. This is the key to solving the statefulness problem: instead of trying to synchronize state across many ephemeral servers, a Durable Object is the authoritative state for a given entity (e.g., a chat room, a document, a game session).

The architecture flows as follows:

  1. A client initiates a WebSocket connection to an endpoint exposed by a Cloudflare Worker.
  2. The Worker receives the upgrade request and, based on the request's path or query parameters (e.g., a roomId), determines which Durable Object instance should handle this specific real-time session.
  3. The Worker fetches or creates the Durable Object instance.
  4. The Worker then passes the WebSocket connection to the Durable Object.
  5. The Durable Object manages all connections for its specific session, handles messages, broadcasts updates to connected clients, and maintains its internal state. Because Durable Objects are single-instance, race conditions and complex state synchronization logic are largely eliminated for that specific session.

3. Step-by-Step Implementation

Let's build a simple chat application to illustrate this architecture. We'll need a Cloudflare Worker to act as the entry point and a Durable Object to manage chat room state and message broadcasting.

Prerequisites:

  • A Cloudflare account
  • wrangler CLI installed (npm i -g wrangler)

Project Setup:

wrangler generate my-realtime-app https://github.com/cloudflare/workers-sdk/tree/main/templates/worker-durable-objects
cd my-realtime-app

worker-configuration.d.ts (Generated by wrangler, defines Durable Object environment)

// Generated by Wrangler
// By default, a Durable Object's environment is a copy of its controlling Worker's environment.
// This file helps you to type the environments for your Durable Objects.
// For example, if you had a Durable Object `MY_DO` with a `fetch` method
// that expected an environment `Env` with a type `SomeType`,
// you could add that to `DurableObjectEnv` like this:
//
// export interface DurableObjectEnv extends Env {
//   MY_DO: SomeType;
// }

interface Env {
  // Durable Object binding for our chat room
  CHAT_ROOM: DurableObjectNamespace;
}

src/index.ts (Cloudflare Worker - Entry Point)

export interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    // Extract room ID from URL path, e.g., /chat/my-room
    const url = new URL(request.url);
    const roomId = url.pathname.slice(1) || 'default-room'; // Use 'default-room' if no path

    // Get the Durable Object ID for this room
    const id = env.CHAT_ROOM.idFromName(roomId);
    // Get a stub for the Durable Object
    const stub = env.CHAT_ROOM.get(id);

    // Forward the request to the Durable Object
    // The Durable Object will handle the WebSocket upgrade
    return stub.fetch(request);
  },
};

export { ChatRoom } from './chat-room'; // Export the Durable Object class

src/chat-room.ts (Durable Object - Manages Chat Room State)

interface Env {}

export class ChatRoom {
  private state: DurableObjectState;
  private sessions: WebSocket[] = [];

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  // Handle HTTP requests (including WebSocket upgrade requests)
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    // Only accept WebSocket upgrade requests
    if (request.headers.get('Upgrade') !== 'websocket') {
      return new Response('Expected Upgrade: websocket', { status: 426 });
    }

    // Create a new WebSocket pair
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // Accept the WebSocket connection on the server side
    await this.handleSession(server);

    // Return the client WebSocket to the browser
    return new Response(null, { status: 101, webSocket: client });
  }

  async handleSession(websocket: WebSocket) {
    this.sessions.push(websocket);
    websocket.accept();

    // Send a welcome message to the new client
    websocket.send(JSON.stringify({ type: 'status', message: 'Welcome to the chat!' }));

    // Event listener for incoming messages from this client
    websocket.addEventListener('message', async event => {
      try {
        const message = JSON.parse(event.data as string);
        // Broadcast the message to all other connected clients
        this.broadcast(JSON.stringify({ type: 'chat', user: message.user, text: message.text }), websocket);
      } catch (err) {
        websocket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON message.' }));
      }
    });

    // Event listener for connection close or error
    websocket.addEventListener('close', () => this.closeSession(websocket));
    websocket.addEventListener('error', () => this.closeSession(websocket));
  }

  private closeSession(websocket: WebSocket) {
    this.sessions = this.sessions.filter(s => s !== websocket);
    this.broadcast(JSON.stringify({ type: 'status', message: 'A user left.' }));
  }

  private broadcast(message: string, sender?: WebSocket) {
    this.sessions.forEach(session => {
      if (session !== sender) { // Don't send back to the sender
        session.send(message);
      }
    });
  }
}

wrangler.toml (Configuration for Cloudflare Worker & Durable Object)

name =
Muhammad Tahir logo

Muhammad Tahir

Building web & mobile apps since 2021. Passionate about clean code and real-world impact.