effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung

Mit der Durable Objects WebSocket Hibernation API die Kosten für Echtzeit-Server um 90 % senken

Mit der Durable Objects WebSocket Hibernation API die Kosten für Echtzeit-Server um 90 % senken

Wer schon einmal mit Durable Objects (DO) einen Echtzeit-Chatroom oder einen Multiplayer-Game-Room gebaut hat, kennt vermutlich den seltsamen Posten auf der Rechnung: Selbst in den frühen Morgenstunden, wenn kaum eine Nachricht durchläuft, häufen sich die Duration-Kosten (GB-s) fast genauso stark an wie tagsüber. Der Übeltäter ist so gut wie immer klassischer WebSocket-Code nach dem Muster server.accept() + addEventListener.

Das Wichtigste in Kürze

Die Wurzel des Problems: Warum dauerhaft aktive WebSockets auch im Leerlauf Geld kosten

Die offizielle Preisdokumentation von Cloudflare definiert die Duration-Abrechnung von Durable Objects wie folgt: Duration wird als Wall-Clock-Zeit berechnet, “während der das Object aktiv ist oder zwar im Leerlauf, aber nicht für Hibernation vorgesehen” — und zwar auf Basis der dem DO zugewiesenen 128 MB, unabhängig vom tatsächlichen Speicherverbrauch. Im Workers-Paid-Plan sind 400.000 GB-s pro Monat inklusive, darüber hinaus kosten 1 Million GB-s $12,50.

Die entscheidende Falle steckt in der Definition von “aktiv”. Beim klassischen Muster — im fetch()-Handler ein new WebSocketPair() erzeugen, server.accept() aufrufen und Nachrichten über server.addEventListener("message", ...) entgegennehmen — muss das DO den JS-Event-Listener durchgehend im Speicher halten, um das nächste Event empfangen zu können. Solange auch nur ein Client verbunden ist, kann die DO-Instanz also nicht evictet werden, selbst wenn faktisch keine Nachrichten fließen — und diese gesamte Zeit wird in GB-s abgerechnet.

Das ist eine völlig andere Achse als die CPU-Zeit-Abrechnung von Workers. Die CPU-Zeit im Workers-Paid-Plan (30 Millionen ms pro Monat inklusive, darüber hinaus $0,02 pro 1 Million ms) wird nur für tatsächlich ausgeführten Code berechnet — die Duration eines DO dagegen wird auch dann berechnet, wenn gar kein Code läuft und nur gewartet wird. Auch wenn um drei Uhr nachts niemand chattet oder ein Nutzer nur in der Game-Lobby eingeloggt bleibt, ohne aktiv zu sein: Das DO muss am Leben bleiben, und die Kosten laufen weiter.

Was die Hibernation API genau tut

Die WebSocket Hibernation API dreht dieses Prinzip um. Laut offizieller Dokumentation wird ein DO im Leerlauf “aus dem Speicher entfernt”, während “die WebSocket-Clients weiterhin mit dem Cloudflare-Netzwerk verbunden bleiben.” Aus Sicht des Clients gab es nie eine Unterbrechung, auch Ping/Pong laufen normal weiter. Trifft ein neues Event ein (eingehende Nachricht, Verbindungsabbau usw.), ruft die Runtime erneut den Constructor auf, um die DO-Instanz neu zu erzeugen, verarbeitet das Event und schickt das DO anschließend wieder in den Ruhezustand.

Der aus Kostensicht entscheidende Satz lautet: “Während der Hibernation fallen keine Duration-Kosten (GB-s) an.” Das heißt: GB-s werden nur in den kurzen Momenten berechnet, in denen das DO tatsächlich eine Nachricht verarbeitet — den weitaus größten Teil der Zeit ist es kostenlos.

Allerdings hibernaisiert ein DO nicht bei jeder Gelegenheit. Die Dokumentation nennt explizit Faktoren, die Hibernation verhindern:

Protokollseitige Ping/Pong-Frames sind dagegen eine Ausnahme. Die Dokumentation stellt klar: “Eingehende Ping-Frames werden automatisch mit Pong beantwortet, und diese Ping/Pong-Verarbeitung beeinträchtigt Hibernation nicht.” Das weit verbreitete Muster “alle 30 Sekunden per setInterval einen Ping senden” ist also der natürliche Feind der Hibernation API — und kann in den meisten Fällen ersatzlos entfernt werden.

Migration: Von addEventListener zu webSocketMessage/webSocketClose

Bestehender Code (ohne Hibernation-Unterstützung)

export class ChatRoom {
  constructor(state, env) {
    this.state = state;
    this.sessions = new Map(); // ws -> { username }
  }

  async fetch(request) {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    server.accept(); // 표준 accept — 하이버네이션 불가
    this.sessions.set(server, { username: null });

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

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

    // 30초마다 ping — setInterval이 DO를 계속 깨워 둔다
    const heartbeat = setInterval(() => server.send("ping"), 30000);
    server.addEventListener("close", () => clearInterval(heartbeat));

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

  broadcast(message) {
    for (const ws of this.sessions.keys()) ws.send(message);
  }
}

Dieser Code ist verbreitet und wirkt völlig natürlich — aber sowohl das In-Memory-Map in this.sessions als auch der setInterval-Timer sorgen dafür, dass das DO niemals hibernaisieren kann.

Code mit Hibernation API

export class ChatRoom {
  constructor(ctx, env) {
    this.ctx = ctx;
    this.env = env;
    // 생성자는 하이버네이션 후 깨어날 때마다 다시 호출된다.
    // 인메모리 Map을 여기서 채우지 않고, getWebSockets()로 복원한다.
  }

  async fetch(request) {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // ws.accept() 대신 acceptWebSocket() — 하이버네이션 가능
    this.ctx.acceptWebSocket(server);

    // 연결별 메타데이터는 attachment로 저장 (최대 16,384바이트)
    server.serializeAttachment({ username: null, joinedAt: Date.now() });

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

  // addEventListener 대신 DO 클래스 메서드로 정의
  async webSocketMessage(ws, message) {
    const data = JSON.parse(message);
    const meta = ws.deserializeAttachment() ?? {};

    if (data.type === "join") {
      meta.username = data.username;
      ws.serializeAttachment(meta);
    }

    this.broadcast(JSON.stringify({ from: meta.username, text: data.text }));
  }

  async webSocketClose(ws, code, reason, wasClean) {
    ws.close(code, reason);
  }

  async webSocketError(ws, error) {
    // 비정상 종료 로깅 등
    console.error("WebSocket error:", error);
  }

  broadcast(message) {
    for (const ws of this.ctx.getWebSockets()) ws.send(message);
  }
}

Zusammengefasst haben sich folgende Stellen geändert:

In der wrangler.toml empfiehlt es sich mittlerweile, ein DO mit SQLite-Backend zu verwenden:

[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]

Muster für Chatrooms und Game-Rooms: Viele Clients über ein einziges DO koordinieren

Ob Chatroom oder rundenbasierter bzw. Echtzeit-Multiplayer-Game-Room — das Muster ist identisch: Ein Room = eine DO-Instanz, und alle WebSockets der in diesem Room verbundenen Clients laufen über dasselbe DO. In dieser Struktur liefert ctx.getWebSockets() direkt “alle Clients in diesem Room” zurück, sodass Broadcasts ganz ohne zusätzliches Tagging oder Filtern möglich sind.

Für Game-Rooms lässt sich das Muster so erweitern: Eingaben aus webSocketMessage fließen in den Spielzustand ein, und bei jedem Tick (oder jeder Eingabe) wird über getWebSockets() iteriert, um Snapshots zu verteilen. Zu beachten ist allerdings, dass ein einzelnes DO einen weichen Durchsatz-Grenzwert von rund 1.000 req/s hat und eingehende Nachrichten auf 32 MiB begrenzt sind. Wer sehr große Rooms (mehrere hundert bis tausend gleichzeitige Verbindungen) über ein einziges DO abwickeln will, stößt hier unter Umständen als Erstes an eine Grenze — für sehr große Rooms bietet sich Sharding über mehrere DOs oder das Bündeln des Broadcast-Fan-outs in Batches an.

Sitzungsbezogener Zustand (Nickname, Team, Charakterposition usw.) lässt sich per serializeAttachment am WebSocket-Objekt anheften und bleibt so über Hibernation-Zyklen hinweg erhalten. Da für Attachments jedoch ein Limit von 16.384 Byte gilt, gehören wachsende Daten wie der gesamte Room-Zustand oder Chat-Logs nicht dorthin, sondern in den persistenten Storage des DO (this.ctx.storage).

Strategien zur Zustandswiederherstellung nach dem Aufwachen aus der Hibernation

Was überlebt und was verloren geht

Hibernation löscht die DO-Instanz — das JavaScript-Objekt und seine Variablen —, nicht aber die Verbindungen oder persistente Daten. Zusammengefasst:

Der Kern der Migration besteht deshalb darin, die Zustandsverwaltung so umzubauen, dass ein jederzeit erneut aufgerufener Constructor kein Problem darstellt. In der Praxis läuft das meist auf folgende Dreiteilung hinaus:

  1. Kurzlebige Metadaten, die nur pro Verbindung gebraucht werden (Nickname, Farbe, Zeitpunkt des letzten Heartbeats usw.) → serializeAttachment
  2. Zustand, den sich der ganze Room teilt, der sich aber neu berechnen lässt (Anzahl aktiver Nutzer, Liste der Online-Nutzer) → im Constructor per this.ctx.getWebSockets() über alle Sockets iterieren und deren Attachments auslesen, um den Zustand ad hoc zu rekonstruieren
  3. Daten, die dauerhaft erhalten bleiben müssen (Chat-Historie, Spielstand, Room-Einstellungen) → über this.ctx.storage.get/put im SQLite-Backend ablegen
export class ChatRoom {
  constructor(ctx, env) {
    this.ctx = ctx;
    // 인메모리 캐시는 "복원 가능한 뷰"로만 취급한다.
    // 실제 참조는 필요할 때마다 getWebSockets()로 다시 얻는다.
  }

  onlineUsernames() {
    return this.ctx.getWebSockets()
      .map((ws) => ws.deserializeAttachment()?.username)
      .filter(Boolean);
  }
}

Heartbeats gehören auf die Protokollebene, nicht in setInterval

Wie bereits erwähnt, verarbeitet die Runtime protokollseitige Ping/Pong-Frames automatisch, ohne dass dies die Hibernation stört. Wird auf Anwendungsebene wirklich ein eigener Heartbeat benötigt, um zu prüfen, ob ein Nutzer noch aktiv ist, sollte dieser nicht direkt per setInterval implementiert werden — besser eignet sich die Alarm-API des DO, um in regelmäßigen Abständen kurz aufzuwachen und den Zustand zu prüfen. Auch Alarme verhindern zwar Hibernation, halten das DO aber — anders als setInterval — nicht dauerhaft fest, sondern lassen es nur zum vorgesehenen Zeitpunkt kurz aufwachen und danach wieder einschlafen.

Die CPU-Zeitlimits gelten unverändert weiter

Auch der webSocketMessage-Handler läuft letztlich auf der Workers-Runtime, weshalb die CPU-Zeitlimits (standardmäßig 30 Sekunden, über limits.cpu_ms in der wrangler.toml auf bis zu 5 Minuten erweiterbar) unverändert gelten. Wer aufwendige synchrone Berechnungen (umfangreiches Sortieren, Kompression usw.) direkt im Message-Handler ausführt, kann an dieses Limit stoßen — hier ist Vorsicht geboten.

Kostenvergleich: dauerhaft aktives DO vs. DO mit Hibernation

Preisstruktur (Workers-Paid-Plan, Stand offizielle Dokumentation Juli 2026)

Posten Freikontingent Überschreitungsgebühr
Requests (HTTP-Requests, RPC, WebSocket-Nachrichten, Alarme) 1 Mio./Monat $0,15 pro 1 Mio.
Duration (GB-s, Wall-Clock, fix auf 128 MB berechnet) 400.000 GB-s/Monat $12,50 pro 1 Mio. GB-s
SQLite-Speicherplatz 5 GB-Monate $0,20 pro GB-Monat
SQLite-Zeilen lesen 25 Mrd. Zeilen/Monat $0,001 pro 1 Mio. Zeilen
SQLite-Zeilen schreiben 50 Mio. Zeilen/Monat $1,00 pro 1 Mio. Zeilen

Der Workers-Paid-Plan selbst kostet $5 Grundgebühr pro Monat und enthält bereits 10 Millionen Requests sowie 30 Millionen ms CPU-Zeit; die DO-Kosten kommen zusätzlich obendrauf. (Zur Einordnung: Ein DO mit SQLite-Backend lässt sich auch im kostenlosen Workers-Free-Plan nutzen — mit Tageslimits von 100.000 Requests/Tag, 13.000 GB-s Duration/Tag, 5 Mio. gelesenen und 100.000 geschriebenen Zeilen pro Tag sowie 5 GB Speicher. Für Prototyping reicht der kostenlose Plan also völlig aus.)

Rechenmodell und Annahmen

Die folgenden Zahlen stammen nicht aus echten Rechnungen, sondern aus einer Simulation, die die oben genannten offiziellen Preisformeln direkt einsetzt. Verwendet wurde dieselbe Formel, die Cloudflare selbst in seiner Preisdokumentation als Beispiel nennt (aktive Sekunden × 128 MB/1 GB = GB-s).

Ergebnis

Variante Duration-Kosten/Monat Request-Kosten/Monat (bei beiden gleich) Summe/Monat
Dauerhaft aktives DO $8.289,40 $777,45 $9.066,85
DO mit Hibernation $77,94 $777,45 $855,39

Gesamteinsparung von rund 90,6 % — die “90 %” in der Überschrift sind also keine Übertreibung, die nur in einem Extremszenario zustande kommt, sondern ein Wert, der schon bei völlig gewöhnlicher Aktivität von etwa einer Nachricht pro Sekunde tatsächlich erreicht wird.

Die Ersparnis hängt stark vom Traffic-Muster ab

Verändert man bei denselben 2.000 DOs und derselben Berechnungsformel nur die Nachrichtenfrequenz, schwankt die Ersparnis erheblich:

Das Muster ist eindeutig: Die Duration-Ersparnis für sich genommen ist praktisch unabhängig vom Traffic-Volumen und bleibt immer groß (in allen drei Szenarien sinkt allein die Duration um über 99 %) — je höher jedoch die Nachrichtenfrequenz, desto größer wird der Anteil der Request-Kosten an den Gesamtkosten, wodurch die Gesamtersparnis sinkt. Bei hochfrequenten Echtzeit-Spielen, die mehrmals pro Sekunde ihren Zustand broadcasten, tragen unabhängig von der Hibernation API zusätzliche Optimierungen wie eine niedrigere Tick-Rate oder Delta-Kompression und Interest Management — also das Reduzieren der Nachrichten selbst — stärker zur Kostensenkung bei.

Migrations-Checkliste und häufige Fallstricke

Fazit

Klassischer, auf addEventListener basierender WebSocket-Code ist an sich kein schlechter Code — er passt nur schlecht zum Abrechnungsmodell von Durable Objects. Ein DO kostet Geld, solange es läuft, und die klassische WebSocket-API hält es am Laufen, solange auch nur eine Verbindung besteht. Die Hibernation API trennt diese beiden Dinge voneinander: Die Verbindung bleibt am Edge bestehen, nur die Rechenlast wird schlafen gelegt — und die strukturelle Verschwendung verschwindet.

Von der API-Oberfläche her ist die Migration selbst überschaubar: accept()acceptWebSocket(), Event-Listener → Klassenmethoden, In-Memory-Zustand → Attachment/Storage. Den größten Teil der eigentlichen Arbeit macht jedoch aus, die Prämisse “der Constructor kann jederzeit erneut aufgerufen werden” konsequent in der gesamten Codebasis zu verankern und versteckte Timer wie einen eigenen Heartbeat zu entfernen, die Hibernation verhindern. Arbeitet man die obige Checkliste Punkt für Punkt ab, lässt sich — je nach Traffic-Muster unterschiedlich, aber bei Workloads mit langen Leerlaufzeiten wie Chat oder rundenbasierten Spielen — meist ohne größeren Aufwand eine Kostenersparnis von rund 90 % erzielen.