Skip to content
Search

Cloudflare Durable Objects

lastmod 2026-04-03created 2025-08-125 edits

A service for implementing a serverless stateful backend.

A kind of Workers, and available only on the Workers Paid Plan. There is a $5 base fee, which includes basic Durable Objects usage. Real-time chat and multiplayer games are given as use cases.

There are options billed per hour, like running EC2 compute, so make sure to turn things off when you're done. In particular, note that it won't shut down unless you empty the attached storage (deleteAll()).

Below is a summary, in MediaWiki syntax, of how to connect WebSockets through a Cloudflare Durable Object.

Cloudflare Durable Object WebSocket connection guide

Covers how to establish and manage WebSocket connections using Cloudflare Workers' Durable Object (DO).

Overview

Cloudflare Workers are stateless by default and cannot directly maintain persistent WebSocket connections. A Durable Object keeps state in a single instance, which makes it well suited to act as a WebSocket server.

Component

Role

Worker

Receives client requests and routes them to the DO

Durable Object

Accepts WebSocket connections and handles messages

Hibernation API

Minimizes memory use of inactive connections

Prerequisites

  • Cloudflare Workers paid plan (Workers Paid, $5/month or more)
  • Wrangler CLI installed
  • DO binding configured in wrangler.json (or wrangler.toml)

Step 1: Wrangler configuration

Declare the Durable Object binding and migrations in wrangler.json.

{
  "main": "./src/worker/index.ts",
  "compatibility_date": "2025-10-08",
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [
      {
        "name": "AGENT_SOCKET",
        "class_name": "AgentSocket"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["AgentSocket"]
    }
  ]
}

Field

Description

name

Binding name accessed from Worker code (env.AGENT_SOCKET)

class_name

DO class name (must be exported)

new_sqlite_classes

List of DO classes that use SQLite-backed storage

Step 2: Implementing the Durable Object class

Basic structure

import { DurableObject } from 'cloudflare:workers';

export class AgentSocket extends DurableObject<Env> {
  async fetch(request: Request): Promise<Response> {
    // WebSocket 업그레이드 처리
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    // 메시지 수신 처리
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
    // 연결 종료 처리
  }

  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    // 에러 처리
  }
}

WebSocket upgrade (fetch method)

Handle the WebSocket handshake in the DO's fetch().

async fetch(request: Request): Promise<Response> {
  const upgradeHeader = request.headers.get('Upgrade');
  if (!upgradeHeader || upgradeHeader.toLowerCase() !== 'websocket') {
    return new Response('Expected WebSocket upgrade', { status: 426 });
  }

  // WebSocketPair 생성 (client ↔ server)
  const pair = new WebSocketPair();
  const [client, server] = Object.values(pair);

  // Hibernation API로 수락 (태그로 식별자 부여)
  this.ctx.acceptWebSocket(server, ['my-tag']);

  return new Response(null, { status: 101, webSocket: client });
}

API

Description

new WebSocketPair()

Creates a client/server socket pair

this.ctx.acceptWebSocket(server, tags)

Accepts the server socket with the Hibernation API. tags is an array of strings

Response(null, { status: 101, webSocket: client })

Returns the client socket as the response

Hibernation API vs legacy API

Item

Legacy API

Hibernation API

How to accept

server.accept()

this.ctx.acceptWebSocket(server)

Event handler

server.addEventListener('message', ...)

Class method webSocketMessage()

Memory use

DO always resident in memory during the connection

DO evicted from memory when inactive (hibernate)

Cost

High (idle connections are billed too)

Low (minimal billing while hibernating)

Tag support

None

Identify sockets with this.ctx.getTags(ws)

Recommended: use the Hibernation API. It is advantageous in terms of cost savings and scalability.

Message handling

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
  if (typeof message === 'string') {
    try {
      const data = JSON.parse(message) as { type?: string };

      if (data.type === 'ping') {
        ws.send(JSON.stringify({ type: 'pong' }));
        return;
      }

      // 비즈니스 로직 처리
      ws.send(JSON.stringify({ type: 'ack', received: data }));
    } catch {
      // JSON이 아닌 메시지
    }
  }
}

Connection close and errors

async webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): Promise<void> {
  const tags = this.ctx.getTags(ws);
  const id = tags[0];
  // 정리 로직 (DB 상태 업데이트 등)
  ws.close(code, reason);
}

async webSocketError(ws: WebSocket, _error: unknown): Promise<void> {
  const tags = this.ctx.getTags(ws);
  const id = tags[0];
  // 에러 처리 로직
  ws.close(1011, 'Unexpected error');
}

Step 3: Routing from the Worker to the DO

The Worker forwards the client's WebSocket request to the DO.

import { Hono } from 'hono';

export { AgentSocket } from './agent-socket';

const app = new Hono<{ Bindings: Env }>();

app.get('/ws/:id', async (c) => {
  const id = c.req.param('id');

  // DO 인스턴스 획득 (같은 id → 같은 인스턴스)
  const doId = c.env.AGENT_SOCKET.idFromName(id);
  const stub = c.env.AGENT_SOCKET.get(doId);

  // 원본 요청을 DO로 전달 (헤더 포함)
  return stub.fetch(
    new Request(c.req.url, {
      headers: c.req.raw.headers,
    })
  );
});

export default app;

Key points:

  • idFromName(id): same string → same DO instance. All WebSockets for the same resource gather in one DO.
  • stub.fetch(): forwards the request from the Worker → DO. It must include the original Upgrade: websocket header.
  • The DO class must be re-exported from the Worker entry file (export { AgentSocket }).

Step 4: Authentication (optional)

Exposing an authentication token directly in the WebSocket URL is dangerous. The one-time ticket pattern is recommended.

Flow diagram

클라이언트                    Worker                    Durable Object
    │                           │                           │
    │  POST /connect            │                           │
    │  Authorization: Bearer <token>                         │
    │ ------------------------->│                           │
    │                           │  토큰 검증 (DB 조회)       │
    │                           │                           │
    │                           │  POST /ticket             │
    │                           │  { ticket, agentId }      │
    │                           │ ------------------------->│
    │                           │                           │  티켓 저장
    │                           │                           │  알람 설정 (TTL)
    │                           │<--------------------------│
    │                           │                           │
    │  { url: "wss://.../ws?ticket=xxx" }                    │
    │ <-------------------------│                           │
    │                           │                           │
    │  GET /ws?ticket=xxx       │                           │
    │  Upgrade: websocket       │                           │
    │ ------------------------->│                           │
    │                           │  stub.fetch(request)      │
    │                           │ ------------------------->│
    │                           │                           │  티켓 검증 & 소모
    │                           │                           │  WebSocket 수락
    │ <=====================================================>│
    │               WebSocket 연결 수립                      │

Storing and verifying tickets (inside the DO)

const TICKET_TTL_MS = 30_000; // 30초

async fetch(request: Request): Promise<Response> {
  const url = new URL(request.url);

  // 티켓 저장 엔드포인트
  if (url.pathname.endsWith('/ticket')) {
    const { ticket, agentId } = await request.json();
    await this.ctx.storage.put(`ticket:${ticket}`, agentId);
    await this.ctx.storage.setAlarm(Date.now() + TICKET_TTL_MS);
    return new Response('ok');
  }

  // WebSocket 업그레이드 시 티켓 검증
  const ticket = url.searchParams.get('ticket');
  if (!ticket) return new Response('Missing ticket', { status: 400 });

  const agentId = await this.ctx.storage.get<string>(`ticket:${ticket}`);
  if (!agentId) return new Response('Invalid or expired ticket', { status: 401 });

  // 일회용: 즉시 삭제
  await this.ctx.storage.delete(`ticket:${ticket}`);

  // ... WebSocket 업그레이드 진행
}

// 만료된 티켓 정리
async alarm(): Promise<void> {
  const entries = await this.ctx.storage.list({ prefix: 'ticket:' });
  if (entries.size > 0) {
    await this.ctx.storage.delete([...entries.keys()]);
  }
}

Step 5: Client connection

JavaScript client example

// 1. 티켓 발급
const res = await fetch('https://example.com/api/agents/my-agent/connect', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer <token>' },
});
const { url } = await res.json();

// 2. WebSocket 연결
const ws = new WebSocket(url);

ws.onopen = () => {
  console.log('연결됨');
  // 하트비트
  setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 30000);
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('수신:', data);
};

ws.onclose = (event) => {
  console.log(`종료: ${event.code} ${event.reason}`);
};

Useful DO storage APIs

Method

Description

this.ctx.storage.put(key, value)

Store a key-value pair

this.ctx.storage.get(key)

Get a value

this.ctx.storage.delete(key)

Delete

this.ctx.storage.list({ prefix })

List by prefix

this.ctx.storage.setAlarm(time)

Schedule an alarm() call at the given time

this.ctx.getWebSockets(tag?)

List of currently connected WebSockets (can filter by tag)

this.ctx.getTags(ws)

Get the tags of a specific socket

Broadcast

To propagate a message to all clients connected to one DO instance:

broadcast(message: string, exclude?: WebSocket): void {
  const sockets = this.ctx.getWebSockets();
  for (const ws of sockets) {
    if (ws !== exclude) {
      ws.send(message);
    }
  }
}

Caveats

  • DO class re-export required: Cloudflare recognizes the class only if you export { AgentSocket } from './agent-socket' in the Worker entry file.
  • When using the Hibernation API: call this.ctx.acceptWebSocket(server), not server.accept(). Don't mix the two approaches.
  • Migration tags: when changing the DO storage schema, add a new tag to the migrations array. Don't modify existing tags.
  • Single-instance guarantee: passing the same string to idFromName() always routes to the same DO instance. You can use this to group WebSockets per resource.
  • WebSocket connection limit: up to about 32,768 concurrent WebSocket connections per DO instance.

wrangler.toml

Check the binding configuration to use Durable Objects.

[[durable_objects.bindings]]
name = "MY_CHAT_ROOM"
class_name = "MyChatRoom"

[[migrations]]
tag = "v1"
new_classes = ["MyChatRoom"]

How to use with protobuf

ts-proto

import { UserProfile } from "./generated/user"; // ts-proto로 생성된 파일

export class UserDO extends DurableObject {
  async saveUser(data: UserProfile) {
    // 1. 데이터를 바이너리로 인코딩
    const encoded = UserProfile.encode(data).finish();
    // 2. DO Storage에 직접 저장 (바이너리 저장이 JSON보다 저렴)
    await this.ctx.storage.put("user_data", encoded);
  }

  async getUser(): Promise<UserProfile | undefined> {
    const stored = await this.ctx.storage.get<Uint8Array>("user_data");
    if (!stored) return undefined;
    // 3. 디코딩하여 객체로 복원
    return UserProfile.decode(stored);
  }
}

Connect RPC

Recommended if you also consider API communication... ?

Reportedly Connect is recommended if you need to communicate directly with clients in gRPC style.... <- needs verification.

protobufjs

The library that was most widely used until now. Instead of the full package with every feature, using protobufjs/light or only the generated static code is recommended.

Hibernation API

The most important point is to delegate state by calling this.ctx.acceptWebSocket(ws).

export class MyChatRoom extends DurableObject {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
  }

  async fetch(request: Request) {
    // 1. WebSocket 업그레이드 요청 확인
    const webSocketPair = new WebSocketPair();
    const [client, server] = Object.values(webSocketPair);

    // 2. 서버 측 소켓 수락 및 'Hibernation' 모드 활성화
    this.ctx.acceptWebSocket(server);

    return new Response(null, { status: 101, webSocket: client });
  }

  // 3. 메시지가 도착했을 때만 DO가 깨어나서 이 메서드를 실행합니다.
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    // 메시지 처리 및 브로드캐스트
    this.ctx.getWebSockets().forEach(peer => {
      if (peer !== ws) peer.send(`상대방: ${message}`);
    });
  }

  // 4. 연결이 끊겼을 때 실행
  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
    console.log("연결 종료");
  }
}

Caveats for state management

In Hibernation mode, the DO is frequently evicted from memory (put to sleep) and brought back. Therefore you must not store data in class member variables.

  • Wrong: this.users = [] (reset when it sleeps)
  • Right: persist state using this.ctx.storage.put() or ws.serializeAttachment().

See also

Favorite site

author Zer0 sitemap