Cloudflare Workers (Durable Objects)
Deploy Unbroken Protocol to Cloudflare’s global edge network using Workers and Durable Objects. This provides a fully managed, globally distributed implementation with SQLite-backed storage.
Features
| Feature | Description |
|---|---|
| Global Edge | Streams run close to users worldwide |
| SQLite Storage | 10GB per stream with automatic persistence |
| TTL Support | Automatic expiration via Cloudflare Alarms API |
| Zero Ops | No infrastructure to manage |
| Full Protocol | Complete Unbroken Protocol implementation |
Installation
npm install @unbroken-protocol/cloudflare# orbun add @unbroken-protocol/cloudflareQuick Start
1. Configure wrangler.toml
name = "my-streams"main = "src/index.ts"compatibility_date = "2024-12-01"compatibility_flags = ["nodejs_compat"]
[[durable_objects.bindings]]name = "UNBROKEN_STREAM"class_name = "UnbrokenStreamDO"
[[migrations]]tag = "v1"new_sqlite_classes = ["UnbrokenStreamDO"]2. Create your worker
export { UnbrokenStreamDO, default } from "@unbroken-protocol/cloudflare"3. Deploy
wrangler deployThat’s it! Your streams are now live on Cloudflare’s global network.
Usage
Once deployed, streams are accessible via standard HTTP:
# Create a streamcurl -X PUT https://my-streams.workers.dev/my-stream \ -H "Content-Type: application/json"
# Append datacurl -X POST https://my-streams.workers.dev/my-stream \ -H "Content-Type: application/json" \ -d '{"event": "hello"}'
# Read (catch-up)curl https://my-streams.workers.dev/my-stream
# Read (long-poll)curl "https://my-streams.workers.dev/my-stream?offset=-1&live=long-poll"
# Read (SSE)curl "https://my-streams.workers.dev/my-stream?offset=-1&live=sse"
# Delete streamcurl -X DELETE https://my-streams.workers.dev/my-streamCustom Worker
For authentication, custom routing, or middleware:
import { UnbrokenStreamDO, UnbrokenEnv } from "@unbroken-protocol/cloudflare"
export { UnbrokenStreamDO }
export default { async fetch(request: Request, env: UnbrokenEnv): Promise<Response> { const url = new URL(request.url)
// Custom authentication const authHeader = request.headers.get("Authorization") if (!authHeader || !validateToken(authHeader)) { return new Response("Unauthorized", { status: 401 }) }
// Custom routing (e.g., prefix streams by user) const userId = getUserIdFromToken(authHeader) const streamPath = `/${userId}${url.pathname}`
// Route to Durable Object const id = env.UNBROKEN_STREAM.idFromName(streamPath) return env.UNBROKEN_STREAM.get(id).fetch(request) },}Configuration
TTL and Expiration
Set stream expiration when creating:
# Expire in 1 hour (3600 seconds)curl -X PUT https://my-streams.workers.dev/temp-stream \ -H "Content-Type: application/json" \ -H "Stream-TTL: 3600"
# Expire at specific timecurl -X PUT https://my-streams.workers.dev/scheduled-stream \ -H "Content-Type: application/json" \ -H "Stream-Expires-At: 2024-12-31T23:59:59Z"TypeScript Client
Use the standard Unbroken Protocol client with pollInterval for efficient polling:
import { stream, UnbrokenStream } from "@unbroken-protocol/client"
// Writeconst ds = new UnbrokenStream({ url: "https://my-streams.workers.dev/events", contentType: "application/json",})await ds.append({ type: "click", x: 100, y: 200 })
// Read with polling (recommended for Cloudflare)const response = await stream({ url: "https://my-streams.workers.dev/events", offset: "-1", live: "long-poll", pollInterval: 1000, // Poll every 1 second})
for await (const chunk of response.jsonStream()) { console.log("Data:", chunk)}React Hook
The React hook supports pollInterval for Cloudflare deployments:
import { useUnbrokenStream } from "@unbroken-protocol/react"
interface Todo { id: string text: string completed: boolean}
function TodoApp() { const { data, insert, isLoading } = useUnbrokenStream<Todo>({ url: "https://my-streams.workers.dev/todos", getKey: (todo) => todo.id, pollInterval: 1000, // Poll every 1 second (cheap on Cloudflare) })
if (isLoading) return <div>Loading...</div>
return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.text}</li> ))} </ul> )}React Query Integration
Use @unbroken-protocol/react-query for TanStack Query integration with built-in polling and optimistic updates:
npm install @unbroken-protocol/react-query @tanstack/react-queryimport { useUnbrokenQuery } from "@unbroken-protocol/react-query"
interface Todo { id: string text: string completed: boolean}
function TodoApp() { const { data, insert, update, remove, isLoading } = useUnbrokenQuery<Todo>({ url: "https://my-streams.workers.dev/todos", getKey: (todo) => todo.id, pollInterval: 1000, // React Query polls every 1s })
if (isLoading) return <div>Loading...</div>
return ( <div> <ul> {data.map((todo) => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={() => update({ ...todo, completed: !todo.completed })} /> {todo.text} <button onClick={() => remove({ ...todo, __deleted: true })}> Delete </button> </li> ))} </ul> <button onClick={() => insert({ id: crypto.randomUUID(), text: "New todo", completed: false, }) } > Add Todo </button> </div> )}Available Hooks
| Hook | Description |
|---|---|
useUnbrokenQuery | Read + write with polling and optimistic updates |
useUnbrokenMutation | Write-only (when you don’t need to read) |
useUnbrokenInfiniteQuery | Paginated reading for large streams |
createUnbrokenQueryOptions | Query options for prefetching/SSR |
Options
const { data, insert, update, remove } = useUnbrokenQuery<Todo>({ url: "https://my-streams.workers.dev/todos", getKey: (todo) => todo.id,
// Polling pollInterval: 1000, // Poll interval in ms
// Filtering isDeleted: (t) => t.__deleted, // Custom deletion check transform: (items) => items.sort((a, b) => a.createdAt - b.createdAt),
// React Query options enabled: true, staleTime: 0, gcTime: 5 * 60 * 1000, queryKey: ["custom", "key"], initialData: [],
// Auth headers: { Authorization: "Bearer token" },})React Query vs useUnbrokenStream
| Feature | useUnbrokenStream | @unbroken-protocol/react-query |
|---|---|---|
| Setup | Minimal | Requires QueryClient |
| Caching | Per-key dedup | Full React Query cache |
| DevTools | None | React Query DevTools |
| SSR/Prefetch | Manual | createUnbrokenQueryOptions |
| Infinite scroll | No | useUnbrokenInfiniteQuery |
| Dependencies | react only | + @tanstack/react-query |
Choose useUnbrokenStream when:
- You want minimal dependencies
- Simple apps without complex caching needs
Choose @unbroken-protocol/react-query when:
- You already use React Query
- You need SSR/prefetching
- You want React Query DevTools
- You need infinite scroll/pagination
Advanced: Custom Durable Object
Extend UnbrokenStreamDO for custom behavior:
import { UnbrokenStreamDO, initSchema, getMetadata, appendMessage, readMessages,} from "@unbroken-protocol/cloudflare"
export class CustomStreamDO extends UnbrokenStreamDO { async fetch(request: Request): Promise<Response> { // Add custom logic before/after standard handling const url = new URL(request.url)
if (url.pathname.endsWith("/stats")) { return this.handleStats() }
return super.fetch(request) }
private handleStats(): Response { // Custom endpoint using exported storage utilities const meta = getMetadata(this.ctx.storage.sql) return Response.json({ offset: meta?.currentOffset, created: meta?.createdAt, }) }}Protocol Support
| Feature | Status |
|---|---|
| PUT (create) | ✅ |
| POST (append) | ✅ |
| GET (catch-up) | ✅ |
| GET (long-poll) | ✅ |
| GET (SSE) | ✅ |
| DELETE | ✅ |
| HEAD (metadata) | ✅ |
| JSON mode | ✅ |
| TTL/expiration | ✅ |
| Sequence numbers | ✅ |
| Array flattening | ✅ |
Cost Optimization
This implementation uses non-blocking patterns to minimize Durable Object duration billing.
The Problem: Duration Billing
Cloudflare charges for Durable Object duration - how long your DO stays active. Traditional streaming keeps DOs alive waiting for data:
Traditional Long-Poll:Client → Server: "Give me new data"Server: Waits up to 25 seconds for new data...Server → Client: Returns data or timeoutDO billed for: 25 seconds
Traditional SSE:Client → Server: "Stream data to me"Server: Keeps connection open, sends keepalives...Server → Client: Streams for ~55 secondsDO billed for: 55 secondsAt $12.50 per million GB-seconds, this adds up quickly with many users.
The Solution: Non-Blocking Pattern
This implementation returns immediately instead of waiting:
Non-Blocking Long-Poll:Client → Server: "Give me new data"Server: Checks for data, returns immediatelyServer → Client: Data (200) or No data (204)DO billed for: ~10ms
Non-Blocking SSE:Client → Server: "Stream data to me"Server: Sends current data, closes connectionServer → Client: One batch of data, then closeDO billed for: ~10msThe client handles polling with pollInterval:
| Pattern | Server Behavior | Client Behavior |
|---|---|---|
| Long-poll | Return 204 immediately | Wait pollInterval, retry |
| SSE | Send data once, close | Reconnect after close |
How It Works
- Server returns immediately - No waiting, no keepalives
- Client receives 204 No Content - Means “no new data yet”
- Client waits
pollInterval- e.g., 1000ms - Client polls again - Repeats until data arrives
// The client handles this automaticallyconst response = await stream({ url: "https://my-streams.workers.dev/events", live: "long-poll", pollInterval: 1000, // Wait 1s between polls})
// Under the hood:// 1. GET /events?offset=-1&live=long-poll// 2. Server returns 204 (no new data)// 3. Client waits 1000ms// 4. GET /events?offset=-1&live=long-poll// 5. Server returns 200 with data// 6. Client processes data, updates offset// 7. Repeat from step 1Cost Comparison
| Scenario | Traditional SSE | Non-blocking + pollInterval |
|---|---|---|
| 1000 users polling | ~$50-100/month | ~$1-5/month |
| DO duration/request | 25-55 seconds | ~10ms |
| Cost reduction | - | ~5000x cheaper |
Choosing pollInterval
Balance latency vs. cost:
| pollInterval | Latency | Requests/user/hour | Best For |
|---|---|---|---|
| 500ms | ≤500ms | 7,200 | Real-time (gaming, chat) |
| 1000ms | ≤1s | 3,600 | Interactive apps |
| 3000ms | ≤3s | 1,200 | Dashboards, feeds |
| 10000ms | ≤10s | 360 | Background sync |
// Real-time chatpollInterval: 500
// Todo apppollInterval: 1000
// Analytics dashboardpollInterval: 3000
// Email notificationspollInterval: 10000Cloudflare vs. Self-Hosted
| Aspect | Cloudflare (this package) | Bun Server |
|---|---|---|
| Long-poll behavior | Returns immediately | Waits server-side (25s) |
| SSE behavior | Sends once, closes | Keeps connection open |
pollInterval | Required for live updates | Not needed |
| Cost model | Pay per request + duration | Fixed server cost |
| Best for | Global, pay-per-use | High-frequency, dedicated |
Important: When migrating between Cloudflare and self-hosted, remember to add/remove pollInterval accordingly.
Limitations
| Limit | Value |
|---|---|
| Storage per stream | 10GB (Durable Object SQLite limit) |
| Request body size | 100MB (Cloudflare Workers limit) |
Pricing
Cloudflare Workers pricing applies:
- Workers: First 10M requests/month free, then $0.50/million
- Durable Objects: $0.15/million requests + $0.20/GB-month storage + $12.50/million GB-seconds duration
With non-blocking patterns, duration costs are negligible (~$0.001/million requests at 10ms each).
See Cloudflare Pricing for details.
Deployment Comparison
| Aspect | Durable Objects | Containers | Self-Hosted Bun |
|---|---|---|---|
| Deployment | Managed edge | Managed container | Self-hosted |
| SSE behavior | Non-blocking | True streaming | True streaming |
| Long-poll | Returns immediately | Waits 30s | Waits 30s |
pollInterval | Required | Not needed | Not needed |
| Latency | Depends on poll | Real-time | Real-time |
| Scaling | Automatic global | Automatic | Manual |
| Storage | SQLite (10GB) | Volume mount | File system |
| Cost | Per-request | Container runtime | Server costs |
Next Steps
- Cloudflare Containers - True SSE streaming
- Quick Start Guide - Protocol basics
- Reading Operations - All read modes
- Writing Operations - Write patterns
- Cloudflare Durable Objects - Platform docs