rifts.to
← Alle Beiträge

So habe ich Echtzeit-Umfragen mit Cloudflare Durable Objects gebaut

2026-04-04

Ich habe rifts.to gebaut – ein Tool für Live-Umfragen, gemacht für alle, die vor Publikum stehen. QR-Code scannen, Frage beantworten, Ergebnisse in Echtzeit mitwachsen sehen. An der Oberfläche ganz simpel. Spannend wird's bei dem, was dahinter steckt – denn Echtzeit-Verteilung an der Edge klingt einfach, ist es aber überhaupt nicht.

In diesem Beitrag geht's um Cloudflare Durable Objects: was sie sind, warum sie für genau dieses Problem das richtige Werkzeug sind und wie die Umsetzung am Ende wirklich aussieht.

Das Problem: Echtzeit an der Edge ist kniffliger, als es klingt

Stell dir die Situation vor. Jemand legt eine Umfrage an und zeigt einen QR-Code. Hundert Leute im Raum scannen ihn und fangen an zu antworten. Das Dashboard der präsentierenden Person muss sich live mitaktualisieren – jede Antwort soll innerhalb von ein, zwei Sekunden auftauchen, ganz ohne Polling.

Server-Sent Events (SSE) sind dafür wie gemacht. Das Dashboard öffnet eine dauerhafte HTTP-Verbindung zum Server, und der schiebt bei jeder Änderung ein Event durch diese Verbindung. Simpel, einseitig, breit unterstützt, ohne den ganzen WebSocket-Handshake-Aufwand.

Aber es gibt einen Haken: SSE braucht eine dauerhafte, zustandsbehaftete Verbindung zu einem einzelnen Serverprozess. Wenn auf einem Edge-Knoten eine Antwort eingeht, musst du das Event an Dashboard-Verbindungen schicken, die womöglich auf anderen Edge-Knoten offen sind. In einer klassischen zustandslosen Architektur löst man das mit einer Pub/Sub-Schicht – Redis, Kafka, so was in der Art.

An der Edge ist das umständlich. Cloudflare Workers sind von Haus aus zustandslos. Du kannst keine Verbindungen über Requests hinweg offen halten. Du kannst keinen Speicher zwischen Aufrufen teilen. Genau das macht sie schnell und günstig – und genau das macht die Echtzeit-Verteilung so schwierig.

Auftritt Durable Objects.

Was Durable Objects wirklich sind

Durable Objects sind Cloudflares Antwort auf zustandsbehaftetes Edge-Computing. Ein Durable Object ist eine JavaScript-Klasse, von der genau eine Instanz existiert und die:

Der entscheidende Punkt ist die Single-Instance-Garantie. Wenn du Traffic per ID an ein Durable Object leitest, landet jeder Request mit dieser ID bei derselben Instanz. Das ist genau die Grundlage, die du für die Verteilung brauchst: Alle SSE-Verbindungen einer Umfrage leben im selben Durable Object, und wenn eine neue Antwort reinkommt, kann sie von einer einzigen Stelle im Speicher aus an alle ausgeliefert werden.

Bei rifts.to bekommt jede Umfrage ihr eigenes Durable Object – einen SurveyRoom. Dieser Raum hält alle offenen SSE-Verbindungen zum Ergebnis-Dashboard der jeweiligen Umfrage.

Die Architektur

So läuft eine Antwort durch das System:

Person im Publikum sendet Antwort
        ↓
Cloudflare Pages (Next.js Edge Route: /api/respond)
        ↓
Prüft Turnstile + schreibt in D1 (SQLite)
        ↓
Holt SurveyRoom Durable Object per Survey-ID
        ↓
Ruft room /broadcast auf
        ↓
SurveyRoom schiebt Event an alle offenen SSE-Verbindungen
        ↓
Dashboard aktualisiert sich sofort

Das Dashboard öffnet eine Verbindung zu /api/admin/[token]/stream – eine Edge Route, die per Admin-Token authentifiziert, die Umfrage nachschlägt und die SSE-Verbindung dann direkt an das SurveyRoom-DO übergibt. Das DO hält diese Verbindung am Leben und schreibt jedes Mal hinein, wenn /broadcast aufgerufen wird.

Wichtig: Der Stream ist über den Admin-Token verschlüsselt, nicht über die Survey-ID. Heißt: Nur das Dashboard der authentifizierten präsentierenden Person bekommt den Live-Feed; das Publikum sendet einfach ab und sieht einen Danke-Screen.

Das SurveyRoom Durable Object

Das DO stellt drei Endpunkte bereit: /connect (einen SSE-Client abonnieren), /broadcast (an alle Clients senden) und /close (ein Abschluss-Event schicken und alle Verbindungen herunterfahren). Hier die komplette Umsetzung:

import type { SSEEvent } from "../lib/types";

export class SurveyRoom implements DurableObject {
  private connections: Set<WritableStreamDefaultWriter<Uint8Array>> = new Set();
  private encoder = new TextEncoder();

  constructor(
    private readonly state: DurableObjectState,
    private readonly env: CloudflareEnv
  ) {}

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

    if (url.pathname === "/connect") {
      return this.handleConnect(request);
    }
    if (url.pathname === "/broadcast" && request.method === "POST") {
      return this.handleBroadcast(request);
    }
    if (url.pathname === "/close" && request.method === "POST") {
      return this.handleClose();
    }

    return new Response("Not found", { status: 404 });
  }

  private handleConnect(request: Request): Response {
    const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
    const writer = writable.getWriter();
    this.connections.add(writer);

    const connectedEvent: SSEEvent = { type: "connected" };
    writer.write(this.encoder.encode(`data: ${JSON.stringify(connectedEvent)}\n\n`));

    const cleanup = () => {
      this.connections.delete(writer);
      writer.close().catch(() => {});
    };
    request.signal.addEventListener("abort", cleanup);

    return new Response(readable, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
      },
    });
  }

  private async handleBroadcast(request: Request): Promise<Response> {
    const event = await request.json<SSEEvent>();
    const message = this.encoder.encode(`data: ${JSON.stringify(event)}\n\n`);

    const dead: WritableStreamDefaultWriter<Uint8Array>[] = [];
    for (const writer of this.connections) {
      try {
        await writer.write(message);
      } catch {
        dead.push(writer);
      }
    }
    dead.forEach((w) => this.connections.delete(w));

    return new Response("ok");
  }

  private async handleClose(): Promise<Response> {
    const event: SSEEvent = { type: "survey_closed" };
    const message = this.encoder.encode(`data: ${JSON.stringify(event)}\n\n`);

    for (const writer of this.connections) {
      try {
        await writer.write(message);
        await writer.close();
      } catch {}
    }
    this.connections.clear();
    return new Response("ok");
  }
}

Ein paar Dinge, die einen Blick wert sind:

Serielle Ausführung. Durable Objects arbeiten innerhalb einer Instanz immer nur einen Request gleichzeitig ab. Ein Broadcast kommt nie einem anderen in die Quere. Keine Locks nötig.

Trennung per Abort-Signal. Statt einer Heartbeat- oder Fehlererkennungs-Schleife läuft das Aufräumen über request.signal. Trennt sich der Client, feuert das Abort-Event und der Writer fliegt sofort aus dem Set. Zur Sicherheit räumt auch die Broadcast-Schleife alle Writer aus, die beim Schreiben einen Fehler werfen.

Der /close-Endpunkt. Wenn eine Umfrage beendet wird, ruft die Admin-Route /close auf. Das schickt allen Clients ein survey_closed-Event und fährt die Verbindungen sauber herunter. So kann der Client-Code reagieren – etwa eine „Umfrage beendet“-Meldung anzeigen – statt die Verbindung einfach kommentarlos zu verlieren.

Das erste Event. Beim Verbinden schreibt das DO sofort ein { type: "connected" }-Event. Das bestätigt dem Client, dass der SSE-Stream steht, und hilft, eine erfolgreiche Verbindung von einer hängenden zu unterscheiden.

Routing zum richtigen Raum

Jede Umfrage bekommt eine Durable-Object-ID, die aus ihrer Survey-ID abgeleitet wird. Im Submission-Handler (/api/respond):

const response = await createResponse(env.DB, surveyId, answers);

const doId = env.SURVEY_ROOM.idFromName(surveyId);
const stub = env.SURVEY_ROOM.get(doId);
await stub.fetch("http://do/broadcast", {
  method: "POST",
  body: JSON.stringify({ type: "new_response", response }),
  headers: { "Content-Type": "application/json" },
}).catch(() => {
  // Unkritisch: Schaut kein Admin zu, läuft der Broadcast einfach ins Leere
});

idFromName() ist deterministisch – derselbe String landet überall in Cloudflares Netzwerk immer bei derselben DO-Instanz. Genau das gibt dir die Single-Instance-Garantie, ganz ohne Koordinierungsschicht.

Der Broadcast ist fire-and-forget (.catch(() => {})). Ist kein Admin-Dashboard verbunden, hat das DO keine offenen Writer und der Broadcast bleibt folgenlos.

In der Stream-Route (/api/admin/[token]/stream):

const survey = await getSurveyByAdminToken(env.DB, token);
if (!survey) {
  return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}

const doId = env.SURVEY_ROOM.idFromName(survey.id);
const stub = env.SURVEY_ROOM.get(doId);

return stub.fetch(new Request("http://do/connect", {
  signal: request.signal,
}));

Die Route authentifiziert und reicht dann das Abort-Signal des Requests an das DO durch, damit es die Verbindungen aufräumen kann, sobald der Browser wegnavigiert.

Die Datenbank: Cloudflare D1

Umfrage-Definitionen und Antworten liegen in D1, Cloudflares SQLite-Angebot für die Edge. D1 passt hier gut, weil Umfragen und Antworten relational sind, SQL das richtige Modell zum Abfragen aggregierter Ergebnisse ist und das Schema simpel genug ist, dass SQLites Grenzen keine Rolle spielen.

Für den SSE-Stream ist die eventuelle Konsistenz der D1-Read-Replicas egal – wir senden die rohen Antwortdaten direkt über das DO, statt nach jeder Abgabe D1 abzufragen. Der erste Seitenaufruf holt die bisherigen Antworten aus D1, und das ist völlig in Ordnung.

Eine echte Einschränkung: D1 serialisiert Schreibzugriffe. Jede Abgabe macht einen seriellen Read plus Write, und bei hoher Last wird genau das zum Flaschenhals. Im Lasttest tauchen ab etwa 400 bis 600 virtuellen Nutzern die ersten Fehler auf. Die Lösung: die D1-Writes per Cloudflare Queues und Batch-Inserts vom heißen Pfad entkoppeln – aber das ist ein Refactoring für später.

Deployment: der umständliche Teil

Cloudflares next-on-pages-Adapter macht aus einer Next.js-App ein Pages-Deployment, aber in der App definierte Durable Objects müssen als separater Worker deployt werden. Die Deploy-Pipeline läuft in vier Schritten:

# 1. Next.js bauen und ins Cloudflare-Format überführen
npx next build && npx @cloudflare/next-on-pages

# 2. SurveyRoom-DO-Klasse in den kompilierten Worker injizieren
node scripts/patch-worker.mjs

# 3. Den DO-Begleit-Worker deployen
wrangler deploy --config wrangler.worker.toml

# 4. Das Pages-Projekt deployen
wrangler pages deploy .vercel/output/static --project-name instant-survey

Das Skript patch-worker.mjs kompiliert SurveyRoom.ts mit esbuild und hängt die Ausgabe vorne an das kompilierte _worker.js-Bundle, damit der Pages-Worker die DO-Klasse exportieren kann. Es funktioniert, ist aber fragil – das Erste, was ich anpacken würde, sobald Cloudflare die Integration von Pages und DOs verbessert.

Was lief, was nicht

Was richtig gut lief:

Was ich anders machen würde:

Probier's aus

rifts.to ist live und kostenlos. Leg eine Umfrage an, schnapp dir den QR-Code und probier's beim nächsten Team-Meeting oder Vortrag. Auf beiden Seiten ganz ohne Anmeldung.

Verwandte Tools

rifts.to kostenlos testen →
rifts