Skip to main content

Realtime & Room Internals

How EdgeBase delivers real-time data subscriptions, presence tracking, broadcast messaging, and server-authoritative game/collaboration rooms.

Realtime Architecture

EdgeBase Realtime runs entirely inside Durable Objects using the Cloudflare WebSocket Hibernation API. There is no external message broker, no pub/sub service, and no per-message billing. Idle connections cost $0.

Channel-DO Mapping

Each Realtime channel maps to a dedicated Durable Object instance. The channel name determines the DO identity:

Channel PatternExampleUse Case
realtime:{namespace}:{table}realtime:shared:postsSubscribe to all changes on a table (static DB)
realtime:{namespace}:{instanceId}:{table}realtime:workspace:ws-456:docsSubscribe to changes in a dynamic DB instance
realtime:{namespace}:{table}:{docId}realtime:shared:posts:abc123Subscribe to a single document
realtime:presence:{channel}realtime:presence:lobbyPresence (online/offline tracking)
realtime:broadcast:{channel}realtime:broadcast:notificationsOne-shot message broadcasting

Realtime DOs do not use SQLite — all state is held in memory because it is inherently ephemeral (connection-based). When a connection closes, the associated state is expected to disappear.

WebSocket Hibernation API

The Hibernation API is the foundation of EdgeBase Realtime's cost model:

Active connections ──── DO is awake, processing messages

All connections idle ──── DO hibernates ($0 duration cost)

Message arrives ────────── DO wakes up instantly

When a DO hibernates, its in-memory state (subscriptions, presence maps, filter registrations) is lost. EdgeBase handles this with the RESYNC protocol.

RESYNC Protocol

When a DO wakes from hibernation, it broadcasts RESYNC messages to all connected clients:

DO wakes up (memory cleared)

├─ Send PRESENCE_RESYNC to all connections
│ → Clients re-send their presence state

└─ Send FILTER_RESYNC to authenticated connections
→ Clients re-send their subscription filters

The SDK handles RESYNC automatically — no developer intervention is needed. There is a deliberate asymmetry: PRESENCE_RESYNC goes to all connections (including unauthenticated ones), while FILTER_RESYNC only goes to authenticated connections (requiring re-auth first).

Authentication Handshake

WebSocket connections use a message-based authentication flow rather than URL query parameters. This prevents tokens from appearing in server access logs, browser history, and Referer headers.

Client                          Server
│ │
├── WebSocket upgrade ─────────►│
│ │
├── { type: "auth", │
│ token: "eyJhbG..." } ───►│── Verify JWT
│ │
│◄── { type: "auth_success", │
│ userId: "..." } ────────│
│ │
│ (Now: subscribe, presence, │
│ broadcast operations) │
  • Authentication must complete within a timeout (default: 5000ms, configurable via realtime.authTimeoutMs)
  • Any subscribe/presence/broadcast request before authentication results in an error and connection termination
  • Authentication state is stored in WebSocket tags (Hibernation API metadata)

Keep-Alive

Clients send { type: "ping" } every 30 seconds. The server responds with { type: "pong" }. This confirms connection liveness and resets the Hibernation idle timer.

Auto Token Refresh

When the Access Token is refreshed (by any mechanism — HTTP request, another tab, scheduled refresh), the SDK's RealtimeClient automatically sends a re-auth message on the existing WebSocket connection:

{ "type": "auth", "token": "new-eyJhbG..." }

The server recognizes this as a re-authentication, updates the auth state, and keeps all existing subscriptions intact.

Event Propagation

When data changes occur, the Database DO notifies the appropriate Realtime DO directly — without routing through the Worker:

Client write


Database DO
├─ Execute SQL (INSERT/UPDATE/DELETE)
├─ Evaluate security rules
└─ stub.fetch() → Realtime DO (direct DO-to-DO call)

├─ Table channel: notify all table subscribers
└─ Document channel: notify single-doc subscribers

Dual Propagation

Every CUD (Create, Update, Delete) event propagates to both the table-level channel and the document-level channel simultaneously. This ensures that subscribers watching the entire table and subscribers watching a specific document both receive real-time notifications.

Event Types

EventTrigger
addedNew record inserted
modifiedExisting record updated
removedRecord deleted
batch_changesMultiple changes in a single transaction (above threshold)

Batch Event Bundling

When a single transaction produces more changes than the batch threshold (default: 10, configurable via realtime.batchThreshold), events are bundled into a single batch_changes message:

{
"type": "batch_changes",
"channel": "realtime:shared:posts",
"changes": [
{ "event": "modified", "data": { "id": "...", "title": "..." } },
{ "event": "modified", "data": { "id": "...", "title": "..." } }
],
"total": 150
}

SDK version negotiation preserves protocol compatibility — older SDKs receive individual events, newer SDKs receive bundled messages.

Server-Side Subscription Filters

Clients can register filters at subscription time to receive only matching events:

{
"type": "subscribe",
"channel": "realtime:shared:posts",
"filters": [["authorId", "==", "user-123"]],
"orFilters": [["status", "==", "published"], ["status", "==", "featured"]]
}

The filter logic translates to: WHERE authorId = ? AND (status = ? OR status = ?).

Filter TypeLogicMax Conditions
filtersAND (all must match)5
orFiltersOR (any must match)5

Filters are additive restrictions — they can only narrow what the security rules already allow, never bypass them. After hibernation wake-up, FILTER_RESYNC prompts the SDK to re-register all filters.

Dynamic Filter Updates

Clients can update their filters without disconnecting using an update_filters message. This replaces the existing filters for a given channel subscription.

Presence

Presence tracks which users are currently connected to a channel, along with optional metadata (e.g., cursor position, typing indicator):

// Join presence
client.realtime.presence('lobby').join({ status: 'online', name: 'Alice' });

// Listen for changes
client.realtime.presence('lobby').onJoin((userId, data) => { ... });
client.realtime.presence('lobby').onLeave((userId, data) => { ... });

Presence TTL and Auto-Cleanup

Clients that disconnect abnormally (network failure, crash, browser close without cleanup) leave orphaned presence entries. EdgeBase handles this with a TTL-based auto-cleanup:

  • Each presence entry tracks a lastSeen timestamp
  • A DO Alarm periodically checks for stale entries
  • Entries exceeding the TTL (default: 60 seconds, configurable via realtime.presenceTTL) are automatically removed
  • A presence_leave event with reason: 'timeout' is broadcast to remaining subscribers

This eliminates ghost users without requiring explicit cleanup logic from the application.

Presence Constraints

  • Payload size limit: 1 KB per presence entry (enforced on both server and SDK)
  • Presence state is memory-only — it does not survive hibernation. The RESYNC protocol restores it from clients.

Channel Access Control

Channel TypeRule Source
DB subscriptions (onSnapshot)Reuses the table's read security rule (evaluated once at subscribe time)
Presence / BroadcastDefined per namespace in realtime.namespaces config
Undefined channelsAuthenticated users only (deny-by-default)
export default defineConfig({
realtime: {
namespaces: {
public: { access: { subscribe: () => true, publish: () => true } },
private: { access: { subscribe: (auth) => auth !== null } },
game: { access: { subscribe: (auth) => auth !== null, publish: (auth) => auth !== null } },
},
},
});

When a user's JWT is refreshed (re-auth), the server re-evaluates all of that user's active subscriptions. If a subscription no longer passes the rules (e.g., membership was revoked), the channel is gracefully unsubscribed and the client is notified via revokedChannels.


Room Architecture

Room is a server-authoritative real-time state channel designed for multiplayer games, collaborative editors, and live dashboards. Unlike Realtime (which is data-driven), Room is action-driven: clients send intentions, the server decides state changes, and all clients receive the authoritative result.

Client A ── send("move", {x:5}) ──►  Room DO (onAction handler)
Client B ── send("attack", {}) ──► │
├─ Server updates state
├─ Delta broadcast to all
└─ Player-specific state unicast

Three State Areas

State AreaVisibilityWriterPurpose
sharedStateAll connected clientsServer onlyGame world, shared document state
playerStateOnly the owning playerServer onlyHand of cards, personal inventory
serverStateServer onlyServer onlyRNG seeds, hidden game logic, timers

Clients never write state directly. They send actions via send(actionType, payload), and the server's onAction handler decides how to modify state.

Delta Broadcasting

When state changes, Room sends only the diff (delta), not the full state:

  • shared_delta — broadcast to all connected clients
  • player_delta — unicast to the specific player only

Deltas are buffered for 50 milliseconds and throttled to 10 messages/second, reducing network overhead for rapid state changes (e.g., real-time game physics).

Zero-Cost Hibernation

When the last player leaves a room, the DO enters hibernation:

  1. All three state areas are persisted to DO Storage
  2. The DO hibernates — $0 duration cost
  3. When a player connects again, state is restored from storage
  4. The room resumes exactly where it left off

State persistence also runs periodically (default: every 60 seconds via stateSaveInterval) to protect against crashes. A stateTTL (default: 24 hours) controls how long persisted state is kept — after expiration, the room starts fresh.

// Manual save is also available
room.saveState();

Lifecycle Hooks

Room created


onCreate ─── Initialize shared/server state


Player connects


onJoin(sender, room) ─── Validate, assign player state
│ (throw to reject)

onAction[type](sender, payload, room)
│ ├─ setSharedState(data) → delta broadcast
│ ├─ setPlayerState(userId, data) → delta unicast
│ └─ setServerState(data) → server only


Player disconnects


onLeave(sender, room) ─── reason: 'leave' | 'disconnect' | 'kicked'


Last player leaves


onDestroy ─── Cleanup, final save, hibernate

Room Features

FeatureDescription
Messagingroom.sendMessage(type, data) for broadcast; room.sendMessageTo(userId, type, data) for unicast
Broadcast Excluderoom.sendMessage(type, data, { exclude: [userId] }) to skip specific players
Kickroom.kick(userId) — triggers onLeave with reason: 'kicked'
Named Timersroom.setTimer(name, ms, data?) / room.clearTimer(name) — persisted across hibernation
Metadataroom.setMetadata(data) — queryable via HTTP without WebSocket (useful for lobbies)
Admin Contextctx.admin is injected into handlers for DB access from within room logic
State Size WarningROOM_STATE_WARNING event fires when cumulative state reaches 80% of maxStateSize

Room Configuration Defaults

SettingDefaultDescription
reconnectTimeout30 secondsHow long to hold a player's slot after disconnect
rateLimit.actions10 (token bucket)Max actions per second per player
maxStateSize1 MBMaximum cumulative state across all three areas
stateSaveInterval60 secondsHow often state is persisted to DO Storage
stateTTL24 hoursHow long persisted state is retained
Action timeout5 secondsMax execution time per onAction handler
Delta buffer50 msDelta batching window

Player Information Security

The server does not automatically expose the player list to clients. To make player information visible, the developer must explicitly share it through setSharedState in the onJoin and onLeave handlers. This prevents unintended leaking of connection metadata.

Next Steps

  • Cost Analysis — Why Realtime and Room cost ~300x less than alternatives
  • Security Model — Channel access control and membership verification