How I Built Real-Time Audience Surveys with Cloudflare Durable Objects
2026-04-04
I built rifts.to — a live audience survey tool for presenters. Scan a QR code, answer a question, watch the results update in real time. Simple on the surface. The interesting part is what makes it actually work under the hood, because real-time fan-out at the edge is a problem that sounds easy and isn't.
This post is about Cloudflare Durable Objects: what they are, why they're the right tool for this specific problem, and what the implementation actually looks like.
The Problem: Real-Time at the Edge Is Harder Than It Sounds
Here's the setup. A presenter creates a survey. They show a QR code. A hundred people in the room scan it and start submitting answers. The presenter's dashboard needs to update in real time — every submission should appear within a second or two, without polling.
Server-Sent Events (SSE) are the natural fit for this. The dashboard opens a persistent HTTP connection to the server, and the server pushes events down that connection whenever something changes. Simple, one-directional, widely supported, no WebSocket handshake complexity.
But there's a catch: SSE requires a persistent, stateful connection to a single server process. When a response comes in on one edge node, you need to push an event to dashboard connections that might be open on other edge nodes. With a traditional stateless architecture, you'd solve this with a pub/sub layer — Redis, Kafka, something like that.
At the edge, that's awkward. Cloudflare Workers are stateless by design. You can't hold open connections across requests. You can't share memory between invocations. That's what makes them fast and cheap — and that's exactly what makes real-time fan-out hard.
Enter Durable Objects.
What Durable Objects Actually Are
Durable Objects are Cloudflare's answer to stateful edge computing. A Durable Object is a single-instance JavaScript class that:
- Lives at a single location in Cloudflare's network (not replicated across regions)
- Has persistent storage via a built-in key-value store
- Handles requests serially within a single instance — no concurrency issues
- Can hold WebSocket or HTTP connections for as long as needed
The key insight is the single-instance guarantee. When you route traffic to a Durable Object by ID, every request with that ID hits the same instance. This is the primitive you need for fan-out: all SSE connections for a given survey live in the same Durable Object, so when a new response arrives, it can push to all of them from a single place in memory.
For rifts.to, every survey gets its own Durable Object — a SurveyRoom. The room holds all the open SSE connections for that survey's results dashboard.
The Architecture
Here's how a submission flows through the system:
Audience member submits answer
↓
Cloudflare Pages (Next.js Edge Route: /api/respond)
↓
Validates Turnstile + writes to D1 (SQLite)
↓
Fetches SurveyRoom Durable Object by survey ID
↓
Calls room /broadcast
↓
SurveyRoom pushes event to all open SSE connections
↓
Presenter's dashboard updates instantly
The dashboard opens a connection to /api/admin/[token]/stream — an edge route that authenticates via admin token, looks up the survey, then delegates the SSE connection directly to the SurveyRoom DO. The DO keeps that connection alive and writes to it whenever /broadcast is called.
Note that the stream is keyed by admin token, not survey ID. This means only the authenticated presenter's dashboard gets the live feed; audience members just submit and see a thank-you screen.
The SurveyRoom Durable Object
The DO exposes three endpoints: /connect (subscribe an SSE client), /broadcast (push to all clients), and /close (send a terminal event and shut down all connections). Here's the full implementation:
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");
}
}
A few things worth noting:
Serial execution. Durable Objects handle one request at a time within an instance. One broadcast won't race with another. No locks needed.
Disconnect via abort signal. Rather than a heartbeat/error-detection loop, cleanup is driven by request.signal. When the client disconnects, the abort event fires and the writer is immediately removed from the set. The broadcast loop also prunes writers that throw on write, as a safety net.
/close endpoint. When the presenter ends a survey, the admin route calls /close, which sends a survey_closed event to all clients and shuts down the connections cleanly. This lets client-side code react — show a "survey ended" message — rather than just losing the connection silently.
Initial event. On connect, the DO immediately writes a { type: "connected" } event. This confirms to the client that the SSE stream is live, and is useful for distinguishing a successful connection from a hung one.
Routing to the Right Room
Each survey gets a Durable Object ID derived from its survey ID. In the 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(() => {
// Non-fatal: if no admin is watching, the broadcast is a no-op
});
idFromName() is deterministic — the same string always maps to the same DO instance, anywhere in Cloudflare's network. This is what gives you the single-instance guarantee without any coordination layer.
The broadcast is fire-and-forget (.catch(() => {})). If no admin dashboard is connected, the DO has no open writers and the broadcast is harmless.
In the 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,
}));
The route authenticates, then passes the request's abort signal through to the DO so it can clean up connections when the browser navigates away.
The Database: Cloudflare D1
Survey definitions and responses live in D1, Cloudflare's edge SQLite offering. D1 is a good fit here because surveys and responses are relational, SQL is the right model for querying aggregated results, and the schema is simple enough that SQLite's limitations don't matter.
For the SSE stream, D1's eventual consistency in read replicas doesn't matter — we broadcast the raw response data directly via the DO rather than querying D1 after each submission. The initial page load queries D1 for historical responses, which is fine.
One real constraint: D1 serializes writes. Each submission does a serial read + write, and at high concurrency this becomes the bottleneck. In load testing, failures start appearing around 400–600 virtual users. The fix is to decouple D1 writes from the hot path via Cloudflare Queues and batch inserts — but that's a future refactor.
Deployment: The Awkward Part
Cloudflare's next-on-pages adapter converts a Next.js app for Pages deployment, but Durable Objects defined in your app need to be deployed as a separate Worker. The deploy pipeline runs four steps:
# 1. Build Next.js and convert to Cloudflare format
npx next build && npx @cloudflare/next-on-pages
# 2. Inject the SurveyRoom DO class into the compiled worker
node scripts/patch-worker.mjs
# 3. Deploy the DO companion Worker
wrangler deploy --config wrangler.worker.toml
# 4. Deploy the Pages project
wrangler pages deploy .vercel/output/static --project-name instant-survey
The patch-worker.mjs script compiles SurveyRoom.ts with esbuild and prepends the output to the compiled _worker.js bundle so that the Pages worker can export the DO class. It works, but it's brittle — the first thing I'd refactor if Cloudflare improves the Pages + DO integration story.
What Worked, What Didn't
What worked well:
- The DO model is genuinely the right abstraction for this problem. Once you internalize the single-instance routing guarantee, the implementation is clean and obvious.
- SSE over Durable Objects is rock solid. The abort-signal-based cleanup is simpler and more reliable than heartbeat polling.
- D1 + DO together cover the full persistence + real-time stack without any external services. The entire app is Cloudflare-native.
What I'd do differently:
- The patch-worker script is a hack. Restructuring the DO as a fully separate Worker with its own domain and proxying to it from the Pages app would be cleaner.
- D1 write throughput is the real scaling ceiling. Decoupling submissions from D1 writes via Queues + batch inserts would push the cap significantly higher.
- I'd add explicit
Last-Event-IDreconnection logic on the client side sooner. SSE connections drop; the browser reconnects automatically, but sequence numbers make it easy to replay missed events.
Try It
rifts.to is live and free. Create a survey, grab the QR code, and try it in your next team meeting or talk. No signup required on either side.