Skip to content

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

FeatureDescription
Global EdgeStreams run close to users worldwide
SQLite Storage10GB per stream with automatic persistence
TTL SupportAutomatic expiration via Cloudflare Alarms API
Zero OpsNo infrastructure to manage
Full ProtocolComplete Unbroken Protocol implementation

Installation

Terminal window
npm install @unbroken-protocol/cloudflare
# or
bun add @unbroken-protocol/cloudflare

Quick 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

src/index.ts
export { UnbrokenStreamDO, default } from "@unbroken-protocol/cloudflare"

3. Deploy

Terminal window
wrangler deploy

That’s it! Your streams are now live on Cloudflare’s global network.

Usage

Once deployed, streams are accessible via standard HTTP:

Terminal window
# Create a stream
curl -X PUT https://my-streams.workers.dev/my-stream \
-H "Content-Type: application/json"
# Append data
curl -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 stream
curl -X DELETE https://my-streams.workers.dev/my-stream

Custom 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:

Terminal window
# 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 time
curl -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"
// Write
const 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:

Terminal window
npm install @unbroken-protocol/react-query @tanstack/react-query
import { 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

HookDescription
useUnbrokenQueryRead + write with polling and optimistic updates
useUnbrokenMutationWrite-only (when you don’t need to read)
useUnbrokenInfiniteQueryPaginated reading for large streams
createUnbrokenQueryOptionsQuery 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

FeatureuseUnbrokenStream@unbroken-protocol/react-query
SetupMinimalRequires QueryClient
CachingPer-key dedupFull React Query cache
DevToolsNoneReact Query DevTools
SSR/PrefetchManualcreateUnbrokenQueryOptions
Infinite scrollNouseUnbrokenInfiniteQuery
Dependenciesreact 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

FeatureStatus
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 timeout
DO billed for: 25 seconds
Traditional SSE:
Client → Server: "Stream data to me"
Server: Keeps connection open, sends keepalives...
Server → Client: Streams for ~55 seconds
DO billed for: 55 seconds

At $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 immediately
Server → 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 connection
Server → Client: One batch of data, then close
DO billed for: ~10ms

The client handles polling with pollInterval:

PatternServer BehaviorClient Behavior
Long-pollReturn 204 immediatelyWait pollInterval, retry
SSESend data once, closeReconnect after close

How It Works

  1. Server returns immediately - No waiting, no keepalives
  2. Client receives 204 No Content - Means “no new data yet”
  3. Client waits pollInterval - e.g., 1000ms
  4. Client polls again - Repeats until data arrives
// The client handles this automatically
const 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 1

Cost Comparison

ScenarioTraditional SSENon-blocking + pollInterval
1000 users polling~$50-100/month~$1-5/month
DO duration/request25-55 seconds~10ms
Cost reduction-~5000x cheaper

Choosing pollInterval

Balance latency vs. cost:

pollIntervalLatencyRequests/user/hourBest For
500ms≤500ms7,200Real-time (gaming, chat)
1000ms≤1s3,600Interactive apps
3000ms≤3s1,200Dashboards, feeds
10000ms≤10s360Background sync
// Real-time chat
pollInterval: 500
// Todo app
pollInterval: 1000
// Analytics dashboard
pollInterval: 3000
// Email notifications
pollInterval: 10000

Cloudflare vs. Self-Hosted

AspectCloudflare (this package)Bun Server
Long-poll behaviorReturns immediatelyWaits server-side (25s)
SSE behaviorSends once, closesKeeps connection open
pollIntervalRequired for live updatesNot needed
Cost modelPay per request + durationFixed server cost
Best forGlobal, pay-per-useHigh-frequency, dedicated

Important: When migrating between Cloudflare and self-hosted, remember to add/remove pollInterval accordingly.

Limitations

LimitValue
Storage per stream10GB (Durable Object SQLite limit)
Request body size100MB (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

AspectDurable ObjectsContainersSelf-Hosted Bun
DeploymentManaged edgeManaged containerSelf-hosted
SSE behaviorNon-blockingTrue streamingTrue streaming
Long-pollReturns immediatelyWaits 30sWaits 30s
pollIntervalRequiredNot neededNot needed
LatencyDepends on pollReal-timeReal-time
ScalingAutomatic globalAutomaticManual
StorageSQLite (10GB)Volume mountFile system
CostPer-requestContainer runtimeServer costs

Next Steps