effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung
Deutsch

Cloudflare DO WebSocket Hibernation: 99% Kostenreduktion

Cloudflare Durable Objects WebSocket Hibernation API Kostenoptimierungsarchitektur

Echtzeit-Chat mit DO erstellt – aber die Rechnung stimmt nicht

Cloudflare Durable Objects (DO) wirken wie die perfekte Plattform zur Implementierung von Echtzeit-Chat, Multiplayer-Spielen und kollaborativen Editoren. Sie überwinden die Zustandslosigkeit von Workers und ermöglichen die Verwaltung von persistentem Speicher und WebSockets an einem Ort.

Nach dem Deployment einer Chat-App sorgt der Blick auf die erste Rechnung jedoch oft für Erstaunen: Selbst bei nur einigen Dutzend gleichzeitigen Benutzern fallen die Kosten 10-mal höher aus als erwartet.

Der Grund dafür ist das GB-Seconds-Abrechnungsmodell. Wenn Sie die Standard-WebSocket-API verwenden, bleibt die DO-Instanz im Arbeitsspeicher und wird durchgehend abgerechnet – selbst wenn Clients lediglich eine Verbindung aufrecht erhalten und keine Nachrichten senden. Wenn 100 Benutzer einem Chatraum beitreten und nur gelegentlich Nachrichten senden, bleibt das DO 24 Stunden am Tag aktiv.

Die Hibernation API ist die Lösung für dieses Problem. Sie ermöglicht es dem DO, aus dem Arbeitsspeicher entfernt zu werden, während die Client-Verbindungen aufrechterhalten werden. Sobald eine neue Nachricht eintrifft, wacht das DO automatisch auf, verarbeitet sie und wechselt wieder in den Ruhezustand. Kosten entstehen nur für die tatsächliche Ausführungszeit des Codes.

In diesem Artikel behandeln wir die Unterschiede zwischen Standard-WebSocket-DO und Hibernation API, die Implementierung einer praktischen Chat-Anwendung, die Zustandsverwaltung mit serializeAttachment sowie detaillierte Kostensimulationen.

Das DO-Abrechnungsmodell verstehen

Bevor Sie die Hibernation API einsetzen, müssen Sie die Abrechnungsstruktur genau verstehen.

Abrechnungselement Beschreibung Einzelpreis
Anfragen (Requests) HTTP-Anfragen, RPC-Sitzungen, WebSocket-Nachrichten $0.15 pro 1 Mio.
Rechendauer (Duration) Zeit des DO im Arbeitsspeicher × 128MB $12.50 pro GB-s
Speicher (Storage) SQLite-Lese-/Schreibzugriffe, KV-Speicher Separat

Wichtige Regel: WebSocket-Nachrichten werden im Verhältnis 20:1 abgerechnet. 100 eingehende WebSocket-Nachrichten = 5 berechnete Anfragen.

Das Problem: Die Duration-Kosten explodieren. Ein DO bleibt nach dem letzten Ereignis mindestens 10 Sekunden im Arbeitsspeicher, und Standard-WebSockets setzen diesen Timer kontinuierlich zurück.

Kostensimulation: Standard-WebSocket

100 Chaträume × 50 gleichzeitige Benutzer, 8 Stunden pro Tag aktiv, basierend auf einem Monat:

Duration: 100 Räume × 1 DO × 128MB × 8 Std. × 30 Tage
= 100 × 0.128GB × 8h × 30d × 3600s/h
= 11.059.200 GB-s

Kosten: 11.059.200 × $12.50 / 1.000.000
≈ $138/Monat (nur Duration)

Bei Nutzung der Hibernation API: Wenn keine Nachrichten eingehen, wechselt das DO nach 10 Sekunden in den Ruhezustand. Die tatsächliche Nachrichtenverarbeitungszeit beträgt nur etwa 1–5% der gesamten Verbindungszeit.

Annahme: 2% aktive Zeit:
11.059.200 × 0.02 = 221.184 GB-s
Kosten: 221.184 × $12.50 / 1.000.000
≈ $2.76/Monat (Duration)

98% Reduktion der Duration-Kosten bei identischem Traffic.

Vergleich: Standard-WebSocket vs. Hibernation API

Standard-WebSocket (Problematisches Muster)

// src/chat-room.ts — Standard-WebSocket (Kostenfalle)
export class ChatRoom implements DurableObject {
  private sessions: Map<WebSocket, { userId: string; username: string }> = new Map();
  private state: DurableObjectState;

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

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

    const [client, server] = Object.values(new WebSocketPair());

    // Problem: server.accept() zwingt das DO dazu, dauerhaft aktiv zu bleiben
    server.accept();

    server.addEventListener("message", (event) => {
      this.handleMessage(server, event.data);
    });

    server.addEventListener("close", () => {
      this.sessions.delete(server);
    });

    const userId = new URL(request.url).searchParams.get("userId") ?? "anonymous";
    this.sessions.set(server, { userId, username: userId });

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

  private handleMessage(sender: WebSocket, message: string | ArrayBuffer) {
    // Broadcast an alle Sitzungen
    const data = JSON.parse(message as string);
    this.sessions.forEach((info, ws) => {
      if (ws !== sender && ws.readyState === WebSocket.READY_STATE_OPEN) {
        ws.send(JSON.stringify({ ...data, from: info.userId }));
      }
    });
  }
}

Problem dieses Codes: Bei Verwendung von server.accept() wechselt das DO niemals in den Ruhezustand (Hibernation). Da this.sessions eine In-Memory-Map ist, bleiben alle Verbindungssitzungen durchgehend im Arbeitsspeicher.

Hibernation API (Korrektes Muster)

// src/chat-room-hibernation.ts — Hibernation API
export class ChatRoom implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

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

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

    const url = new URL(request.url);
    const userId = url.searchParams.get("userId") ?? crypto.randomUUID();
    const username = url.searchParams.get("username") ?? "Anonymous";
    const roomId = url.searchParams.get("roomId") ?? "default";

    const [client, server] = Object.values(new WebSocketPair());

    // Hauptunterschied: ctx.acceptWebSocket() aktiviert die Hibernation
    this.state.acceptWebSocket(server);

    // serializeAttachment: Hängt Metadaten an dieses WebSocket-Objekt an
    // Bleibt auch nach dem Hibernate-Zustand an den jeweiligen Socket gebunden
    server.serializeAttachment({ userId, username, roomId });

    // Beitrittsnachricht broadcasten
    this.broadcast(server, {
      type: "system",
      message: `${username} hat den Raum betreten`,
      timestamp: Date.now(),
    });

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

  // Event-Handler der Hibernation API: Als reguläre Methoden deklarieren
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    // deserializeAttachment: Stellt angehängte Daten beim Aufwachen nach dem Hibernate wieder her
    const { userId, username, roomId } = ws.deserializeAttachment() as {
      userId: string;
      username: string;
      roomId: string;
    };

    let data: any;
    try {
      data = JSON.parse(message as string);
    } catch {
      ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
      return;
    }

    // Chat-Nachricht verarbeiten
    if (data.type === "message") {
      const chatMessage = {
        type: "message",
        userId,
        username,
        content: data.content,
        timestamp: Date.now(),
        id: crypto.randomUUID(),
      };

      // Verlauf speichern (integrierte DO-SQLite-Datenbank)
      await this.state.storage.put(`msg:${chatMessage.id}`, chatMessage);

      // An alle Teilnehmer im Raum broadcasten
      this.broadcast(ws, chatMessage);
    }
  }

  // WebSocket-Schließungs-Handler
  async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
    const { username, roomId } = ws.deserializeAttachment() as {
      username: string;
      roomId: string;
    };

    this.broadcast(ws, {
      type: "system",
      message: `${username} hat den Raum verlassen`,
      timestamp: Date.now(),
    });
  }

  // WebSocket-Fehler-Handler
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    console.error("WebSocket error:", error);
    ws.close(1011, "Internal Error");
  }

  // Broadcast an alle WebSockets im aktuellen Raum
  private broadcast(sender: WebSocket, data: object): void {
    const message = JSON.stringify(data);
    const senderAttachment = sender.deserializeAttachment() as { roomId: string };

    // getWebSockets(): Gibt alle aktuell verbundenen Sockets zurück, selbst im Hibernate-Zustand
    for (const ws of this.state.getWebSockets()) {
      const attachment = ws.deserializeAttachment() as { roomId: string };
      // Nur an Sockets im selben Raum senden (ohne den Absender)
      if (ws !== sender && attachment.roomId === senderAttachment.roomId) {
        try {
          ws.send(message);
        } catch {
          // Bereits geschlossene Sockets ignorieren
        }
      }
    }
  }
}

Kernkonzepte der Hibernation API

ctx.acceptWebSocket() vs. server.accept()

Feature server.accept() ctx.acceptWebSocket()
Hibernation möglich ❌ Nicht möglich ✅ Möglich
Event-Handler addEventListener webSocketMessage-Methode
Sitzungsverwaltung In-Memory Map getWebSockets()
Zustandserhaltung Im Speicher erforderlich serializeAttachment

serializeAttachment / deserializeAttachment

Hibernation entfernt das DO vollständig aus dem Arbeitsspeicher. Sobald eine neue Nachricht eintrifft, wird eine neue Instanz erstellt. Daher müssen die erforderlichen Metadaten direkt an die einzelnen WebSocket-Sockets angehängt werden:

// Anfügen (automatische Serialisierung vor dem Hibernate)
server.serializeAttachment({
  userId: "user-123",
  username: "Alice",
  roomId: "room-general",
  joinedAt: Date.now(),
  permissions: ["read", "write"],
});

// Wiederherstellen (beim Aufwachen nach dem Hibernate)
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
  const state = ws.deserializeAttachment() as {
    userId: string;
    username: string;
    roomId: string;
    joinedAt: number;
    permissions: string[];
  };

  // state.userId, state.username usw. sicher verwendbar
}

Wichtiger Hinweis: Die Daten in serializeAttachment müssen JSON-serialisierbar sein. Funktionen, Klasseninstanzen und zirkuläre Referenzen können nicht gespeichert werden.

getWebSockets() – Abfragen aktuell verbundener Sockets

// Anzahl angemeldeter Benutzer pro Raum abfragen
getConnectedUsers(roomId: string): number {
  return this.state.getWebSockets()
    .filter(ws => {
      const att = ws.deserializeAttachment() as { roomId: string };
      return att.roomId === roomId;
    })
    .length;
}

// Nachricht nur an einen bestimmten Benutzer senden
sendToUser(targetUserId: string, data: object): void {
  for (const ws of this.state.getWebSockets()) {
    const { userId } = ws.deserializeAttachment() as { userId: string };
    if (userId === targetUserId) {
      ws.send(JSON.stringify(data));
      break;
    }
  }
}

Vollständige Implementierung einer Chat-App

Workers-Einstiegspunkt

// src/index.ts
import { Hono } from "hono";
import { ChatRoom } from "./chat-room-hibernation";

type Env = {
  CHAT_ROOM: DurableObjectNamespace;
};

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

// WebSocket-Upgrade-Handler
app.get("/ws", async (c) => {
  const roomId = c.req.query("roomId") ?? "general";
  const userId = c.req.query("userId") ?? crypto.randomUUID();
  const username = c.req.query("username") ?? "Anonymous";

  // DO-Instanz basierend auf der Raum-ID auswählen (1 DO pro Raum)
  const id = c.env.CHAT_ROOM.idFromName(roomId);
  const room = c.env.CHAT_ROOM.get(id);

  // Anfrage an DO weiterleiten
  return room.fetch(c.req.raw);
});

// API zur Abfrage von Rauminformationen
app.get("/rooms/:roomId/info", async (c) => {
  const roomId = c.req.param("roomId");
  const id = c.env.CHAT_ROOM.idFromName(roomId);
  const room = c.env.CHAT_ROOM.get(id);
  return room.fetch(
    new Request(`https://internal/info?roomId=${roomId}`)
  );
});

export default app;
export { ChatRoom };

wrangler.jsonc-Konfiguration

// wrangler.jsonc
{
  "name": "realtime-chat",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],

  "durable_objects": {
    "bindings": [
      {
        "name": "CHAT_ROOM",
        "class_name": "ChatRoom"
      }
    ]
  },

  "migrations": [
    {
      "tag": "v1",
      "new_classes": ["ChatRoom"]
    }
  ],

  // OTel-Tracing ebenfalls aktivieren (Kostenüberwachung)
  "observability": {
    "traces": {
      "enabled": true,
      "head_sampling_rate": 0.1
    }
  }
}

Client-Implementierung (Hono + Vanilla JS)

// public/client.ts
class ChatClient {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnects = 5;

  connect(roomId: string, userId: string, username: string): void {
    const url = new URL("/ws", window.location.href);
    url.protocol = url.protocol.replace("http", "ws");
    url.searchParams.set("roomId", roomId);
    url.searchParams.set("userId", userId);
    url.searchParams.set("username", username);

    this.ws = new WebSocket(url.toString());

    this.ws.addEventListener("open", () => {
      console.log("Connected to chat room");
      this.reconnectAttempts = 0;
    });

    this.ws.addEventListener("message", (event) => {
      const data = JSON.parse(event.data);
      this.onMessage(data);
    });

    this.ws.addEventListener("close", (event) => {
      console.log(`Disconnected: ${event.code} ${event.reason}`);
      // Automatische Wiederverbindung (exponentieller Backoff)
      if (this.reconnectAttempts < this.maxReconnects) {
        const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
        setTimeout(() => {
          this.reconnectAttempts++;
          this.connect(roomId, userId, username);
        }, delay);
      }
    });
  }

  sendMessage(content: string): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type: "message", content }));
    }
  }

  private onMessage(data: any): void {
    // UI-Aktualisierungslogik
    const event = new CustomEvent("chat-message", { detail: data });
    window.dispatchEvent(event);
  }

  disconnect(): void {
    this.ws?.close(1000, "User disconnected");
  }
}

export const chatClient = new ChatClient();

Verlaufsverwaltung: Integrierte DO-SQLite-Datenbank

Um den Chatverlauf auch nach dem Hibernate-Zustand zu behalten, verwenden Sie den persistenten Speicher des DO:

// Chatverlauf speichern und abfragen
export class ChatRoom implements DurableObject {
  // Nachricht speichern
  private async saveMessage(msg: ChatMessage): Promise<void> {
    // Integrierte DO-SQLite: Strukturierte Abfragen möglich
    await this.state.storage.put(`msg:${msg.timestamp}:${msg.id}`, msg);

    // Nur die letzten 100 Nachrichten behalten (Kostenreduzierung)
    const allKeys = await this.state.storage.list({ prefix: "msg:", limit: 200 });
    if (allKeys.size > 100) {
      // Ältere Nachrichten löschen
      const keysToDelete = [...allKeys.keys()].slice(0, allKeys.size - 100);
      await this.state.storage.delete(keysToDelete);
    }
  }

  // Neuesten Verlauf an neue Verbindungen senden
  private async sendHistory(ws: WebSocket): Promise<void> {
    const history = await this.state.storage.list({
      prefix: "msg:",
      limit: 50,
      reverse: true, // Neueste zuerst
    });

    const messages = [...history.values()].reverse();
    ws.send(JSON.stringify({ type: "history", messages }));
  }
}

Alarm API: Automatische Bereinigung inaktiver Räume

Die regelmäßige Bereinigung von DO-Instanzen inaktiver Chaträume senkt zusätzlich die Speicherkosten:

export class ChatRoom implements DurableObject {
  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    // Alarm einrichten: Inaktivität nach 24 Stunden prüfen
    this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
  }

  // Aufgerufen, wenn der Alarm ausgelöst wird
  async alarm(): Promise<void> {
    const activeConnections = this.state.getWebSockets().length;

    if (activeConnections === 0) {
      // Wenn keine Verbindungen bestehen, ältere Nachrichten bereinigen
      const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; // Vor 7 Tagen
      const allMsgs = await this.state.storage.list({ prefix: "msg:" });

      const toDelete: string[] = [];
      for (const [key, value] of allMsgs) {
        const msg = value as ChatMessage;
        if (msg.timestamp < cutoff) {
          toDelete.push(key);
        }
      }

      if (toDelete.length > 0) {
        await this.state.storage.delete(toDelete);
        console.log(`Cleaned ${toDelete.length} old messages`);
      }
    }

    // Nächsten Alarm planen
    await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
  }
}

Checkliste zur Kostenoptimierung

Liste von Optimierungen für den Produktionseinsatz:

Hibernation aktivieren:

WebSocket-Nachrichten optimieren:

Speicher reduzieren:

Monitoring:

Troubleshooting: Wenn Hibernation nicht funktioniert

Symptom: Duration-Kosten bleiben hoch

Ursache 1: ctx.acceptWebSocket() wurde nicht anstelle von server.accept() verwendet
Ursache 2: Im webSocketMessage-Handler werden lang laufende asynchrone Aufgaben ausgeführt
Ursache 3: Es existieren Timer oder rekursive Aufgaben, die die Event-Loop des DO blockieren

// ❌ Falsches Muster: setInterval hält das DO dauerhaft wach
constructor(state: DurableObjectState) {
  this.state = state;
  setInterval(() => this.cleanup(), 60000); // Stört Hibernation
}

// ✅ Korrektes Muster: Alarm API verwenden
constructor(state: DurableObjectState) {
  this.state = state;
  // Keine schweren Arbeiten im Konstruktor ausführen
}

async fetch(request: Request): Promise<Response> {
  // Alarm nur bei der ersten Verbindung setzen
  const alarmTime = await this.state.storage.getAlarm();
  if (!alarmTime) {
    await this.state.storage.setAlarm(Date.now() + 60000);
  }
  // ...
}

Symptom: deserializeAttachment-Fehler nach dem Hibernate

Ursache: serializeAttachment enthält Werte, die nicht JSON-serialisierbar sind
Lösung: Nur reine JSON-kompatible Objekte anhängen

// ❌ Nicht serialisierbar
server.serializeAttachment({
  userId: "user-123",
  handler: () => {}, // Funktion ist nicht serialisierbar
  socket: server,   // Zirkuläre Referenz
});

// ✅ Serialisierbar
server.serializeAttachment({
  userId: "user-123",
  username: "Alice",
  roomId: "general",
  permissions: ["read", "write"],
  joinedAt: Date.now(), // Nur Zahlen, Strings, Arrays
});

Fazit

Cloudflare Durable Objects bieten eine hervorragende Edge-Runtime für Echtzeit-WebSocket-Anwendungen. Allerdings explodieren die Kosten schnell, wenn man den Unterschied zwischen der Standard-WebSocket-API und der Hibernation API außer Acht lässt.

Wichtigste Umstellungspunkte:

Vorher Nachher Effekt
server.accept() ctx.acceptWebSocket() Hibernation aktivieren
Map<WebSocket, data> serializeAttachment In-Memory-Zustand entfernen
addEventListener webSocketMessage-Methode Auf DO-Event-Modell umstellen
setInterval Alarm API Kosten für periodische Aufgaben senken

Durch diese vier Musteränderungen reduzieren sich die Duration-Kosten um 95–99%. Solange während aufrechterhaltener Client-Verbindungen keine Aktivität stattfindet, wechselt das DO in den Ruhezustand. Sobald eine Nachricht eintrifft, wacht es auf, verarbeitet diese und schläft wieder ein.

Bei Produktionsanwendungen mit Millionen monatlicher WebSocket-Verbindungen bedeutet dieser Unterschied eine Ersparnis von mehreren Hundert Dollar pro Monat.

Ähnlicher Artikel: Lesen Sie Cloudflare Workers OpenTelemetry: OTLP Observability-Kosten senken, um zu erfahren, wie Sie verteiltes Tracing für das gesamte System einschließlich DOs einrichten.