Skip to main content

Architecture

EdgeBase Realtime is built on Cloudflare Durable Objects with the WebSocket Hibernation API. Each channel maps to a single DO instance — idle connections cost $0 and wake instantly when new data arrives.

Channel-DO Mapping

Every realtime channel maps to one RealtimeDO instance via idFromName(). The channel name determines the DO identity:

PatternExampleUse Case
realtime:{ns}:{table}realtime:shared:postsTable subscription (static DB)
realtime:{ns}:{id}:{table}realtime:workspace:ws-456:docsTable subscription (dynamic DB)
realtime:{ns}:{table}:{docId}realtime:shared:posts:abcSingle document subscription
realtime:presence:{channel}realtime:presence:lobbyPresence tracking
realtime:broadcast:{channel}realtime:broadcast:chat-roomBroadcast messaging
  • No SQLite — RealtimeDO is purely memory-based. Presence state exists only while connections are active.
  • Channel = DO instance — Each unique channel name creates a separate DO, providing natural isolation and horizontal scaling.

WebSocket Connection Flow

Client → HTTPS GET /api/realtime?channel=X


Worker (Hono Router)
│ 1. DDoS defense: check IP pending count (max 5, TTL 10s)
│ 2. Resolve DO: REALTIME.idFromName(channel)
│ 3. Forward WebSocket upgrade to DO

RealtimeDO
│ 4. Accept WebSocket, start auth timeout (5000ms)
│ 5. Wait for auth message: { type: 'auth', token: '...' }
│ 6. Verify JWT → auth_success or auth_error

Authenticated Connection — ready for subscribe/presence/broadcast

DDoS Defense

The Worker layer implements IP-based rate limiting to prevent unauthenticated WebSocket flood attacks:

  • Max 5 pending connections per IP address
  • TTL 10 seconds — counter auto-expires, no cleanup needed
  • KV-based counter: +1 on connect, -1 on auth success
  • Exceeding the limit returns HTTP 429 Too Many Requests
  • The global rate limiter (10,000,000 req/60s) acts as a final safety net

Authentication Handshake

After WebSocket upgrade, the first message must be an authentication message:

// Step 1: Client → Server
{ "type": "auth", "token": "eyJhbG...", "sdkVersion": "0.1.0" }

// Step 2a: Server → Client (success)
{ "type": "auth_success", "userId": "user_123" }

// Step 2b: Server → Client (failure — connection closed)
{ "type": "auth_error", "message": "Invalid or expired token" }
FieldRequiredDescription
typeYesMust be "auth"
tokenYesJWT access token
sdkVersionNoSDK version for feature negotiation (e.g., batch support)

Why message-based authentication? Sending the token via URL query parameter (?token=...) would expose it in server access logs, browser history, and Referer headers. The Sec-WebSocket-Protocol header was considered but has incomplete browser support. Message-based auth keeps the token out of all HTTP-visible channels.

Token Refresh

Already-authenticated connections can send a new auth message at any time:

// Client → Server (refresh)
{ "type": "auth", "token": "new-eyJhbG..." }

// Server → Client (success — includes revoked channels if any)
{ "type": "auth_refreshed", "userId": "user_123", "revokedChannels": ["realtime:shared:secret-docs"] }

// Server → Client (failure — existing auth preserved, non-fatal)
{ "type": "error", "code": "AUTH_REFRESH_FAILED", "message": "Token refresh failed — existing auth preserved" }

On successful refresh, the server re-evaluates all subscribed channels. See Access Rules — Permission Re-evaluation.

Event Propagation

When data changes in a Database DO, the event is propagated to subscribers:

Client B → insert(post) → DatabaseDO

│ stub.fetch() — direct DO-to-DO call (no Worker hop)

RealtimeDO (table channel: realtime:shared:posts)
│ Evaluate server-side filters per subscriber
│ Broadcast to matching subscribers

Client A ← { type: 'added', data: {...} }

RealtimeDO (document channel: realtime:shared:posts:post-id)
│ Broadcast to document subscribers

Client C ← { type: 'added', data: {...} }

Dual Propagation

Every CUD (Create/Update/Delete) event is broadcast to both the table channel and the document channel simultaneously:

  • Table channel (realtime:shared:posts) — receives all changes for the entire table
  • Document channel (realtime:shared:posts:post-id) — receives changes only for that specific document

This means table-level subscribers and document-level subscribers both get notified without any extra work.

Event Types

EventDescriptiondata Field
addedNew document createdFull document data
modifiedExisting document updatedFull document data (after update)
removedDocument deletednull

Batch Event Delivery

When a single transaction produces many changes (above the batchThreshold, default 10), the server bundles them into a single batch_changes message:

{
"type": "batch_changes",
"channel": "realtime:shared:posts",
"changes": [
{ "event": "modified", "docId": "post_1", "data": { "title": "Updated" } },
{ "event": "modified", "docId": "post_2", "data": { "title": "Also updated" } }
],
"total": 150
}

SDK Version Negotiation

The server uses the sdkVersion sent during authentication to determine batch support:

  • Modern SDKs (with sdkVersion): Receive batch_changes messages, unpacked by the SDK automatically
  • Legacy SDKs (no sdkVersion): Receive individual events one at a time (backward compatible)

Hibernation & Recovery

Cloudflare's Hibernation API allows idle WebSocket connections to be suspended with zero cost:

  1. Active — WebSocket is processing messages normally
  2. Hibernating — No messages for a period → DO suspends, memory freed, $0 duration billing
  3. Waking — New message arrives → DO wakes, memory is empty, connections are preserved

On wake-up, all in-memory state (presence maps, filter registrations, metadata cache) is lost. The RESYNC protocol handles recovery:

RESYNC Protocol

MessageTargetPurpose
PRESENCE_RESYNCAll sockets (authenticated or not)Clients re-send their presence state
FILTER_RESYNCAuthenticated sockets onlyClients re-register their filter conditions

Why the asymmetry? Unauthenticated clients cannot have active filters (filters require a subscription, which requires auth), but they may need to know the DO has woken up to initiate re-authentication. PRESENCE_RESYNC goes to all sockets to give every client a chance to re-auth and re-track.

The SDK handles RESYNC transparently — your callbacks continue working without interruption.

Error Codes

CodeDescriptionConnection Impact
AUTH_TIMEOUTNo auth message within timeout periodConnection closed
AUTH_FAILEDInvalid or expired JWT tokenConnection closed
AUTH_REFRESH_FAILEDRe-auth token invalidConnection preserved (existing auth kept)
SERVER_ERRORJWT secret not configured on serverConnection closed
NOT_AUTHENTICATEDMessage sent before completing authError response, connection preserved
INVALID_JSONUnparseable WebSocket messageError response
UNKNOWN_TYPEUnrecognized message typeError response
INVALID_CHANNELMissing channel name in subscribeError response
INVALID_FILTERSFilter validation failed (>5 conditions, bad format)Error response, subscription rejected
CHANNEL_ACCESS_DENIEDNo permission for the requested channelError response
NOT_SUBSCRIBEDFilter update on a channel not subscribed toError response
INVALID_STATEPresence state object missing or malformedError response
PRESENCE_TOO_LARGEPresence state exceeds 1KB limitError response
INVALID_EVENTBroadcast message missing event nameError response
FORCE_DISCONNECTAdmin-initiated session terminationConnection closed