Skip to main content

Database Subscriptions & Room Internals

How EdgeBase delivers real-time database subscriptions and server-authoritative game/collaboration rooms.

Database Subscription Architecture

Database subscriptions run inside DatabaseLiveDO (a Durable Object) 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 database subscription channel maps to a dedicated Durable Object instance:

Channel PatternExampleUse Case
dblive:{namespace}:{table}dblive:app:postsSubscribe to all changes on a table (static DB)
dblive:{namespace}:{instanceId}:{table}dblive:workspace:ws-456:docsSubscribe to changes in a dynamic DB instance
dblive:{namespace}:{table}:{docId}dblive:app:posts:abc123Subscribe to a single document

DatabaseLiveDO does 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 Database Subscriptions' 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 and 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 FILTER_RESYNC to authenticated connections
→ Clients re-send their subscription filters

The SDK handles RESYNC automatically — no developer intervention is needed. 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 operations) │
  • Authentication must complete within a timeout (default: 5000ms, configurable via databaseLive.authTimeoutMs)
  • Any subscribe 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 DatabaseLiveClient 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 DatabaseLiveDO directly — without routing through the Worker:

Client write


Database DO
├─ Execute SQL (INSERT/UPDATE/DELETE)
├─ Evaluate security rules
└─ stub.fetch() → DatabaseLiveDO (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 databaseLive.batchThreshold), events are bundled into a single batch_changes message:

{
"type": "batch_changes",
"channel": "dblive:app: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": "dblive:app: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.

Subscription Access Control

Database subscriptions reuse the table's read security rule, evaluated once at subscribe time. If the user does not have read access to the table, the subscription is rejected.

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 Database Subscriptions (which are 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).

Members (Presence)

Room includes built-in member tracking via room.members. This replaces the standalone presence system — all presence is now scoped to rooms.

const room = client.room('game', 'lobby');
await room.connect();
room.members.setState({ status: 'online', name: 'Alice' });

room.members.onJoin((member) => { ... });
room.members.onLeave((member) => { ... });

Member Cleanup on Disconnect

Clients that disconnect abnormally (network failure, crash, browser close without cleanup) are handled via the reconnect timeout:

  • When a connection closes, the server holds the member's slot for the reconnect timeout (default: 30 seconds, configurable via reconnectTimeout)
  • If the client does not reconnect within the timeout, the member entry is removed
  • A member_leave event with reason: 'disconnect' is broadcast to remaining members
  • Explicit disconnects produce reason: 'leave', and server kicks produce reason: 'kicked'

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

Member Constraints

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

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
Membersroom.members — built-in presence tracking with join/leave events and TTL cleanup
Signalsroom.signals — lightweight pub/sub for custom coordination (WebRTC signaling, cursors)
Media Metadata (alpha)room.media — track audio/video/screen publish/mute state for conferencing UIs

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 Database Subscriptions and Room cost ~300x less than alternatives
  • Security Model — Channel access control and membership verification