Cloudflare DO WebSocket Hibernation: 99% Kostenreduktion

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:
- Umstellung von
server.accept()aufthis.state.acceptWebSocket(server) - Wechsel von Event-Listenern zu den Methoden
webSocketMessage,webSocketCloseundwebSocketError - Umstieg von
Map<WebSocket, data>aufserializeAttachment/deserializeAttachment - In-Memory-Speicher
this.sessionsentfernen
WebSocket-Nachrichten optimieren:
- Unnötige Ping/Pong-Nachrichten (Client → Server) entfernen
- Serverseitige Heartbeats entfernen, da Cloudflare Pings automatisch verarbeitet
- Anzahl der Anfragen durch Nachrichten-Batching reduzieren
Speicher reduzieren:
- Obergrenze für Chatverlauf festlegen (100–200 Nachrichten)
- Inaktive Räume regelmäßig per Alarm API bereinigen
- Große Payloads in R2 speichern und im DO nur Referenzen halten
Monitoring:
- DO-Duration-Metriken im Cloudflare Dashboard wöchentlich überprüfen
- Tatsächliche Aktivitätszeit mit OTel messen (Verhältnis Active Duration / Total Connection Time)
- Metriken zur Benutzeranzahl pro Raum erfassen
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.