Real-time & Notifications

How changes propagate — from a button click on one device to a DOM update on another.

The full cycle

Alice clicks "complete"
  → store.set(task, 'status', 'COMPLETED')
  → onChange fires → Alice's UI updates instantly
  → debounce timer starts (800ms)
  → PUT to https://pod.example/tasks.jsonld
  → server responds with Updates-Via: wss://pod.example/ws
  → store connects WebSocket, sends: sub https://pod.example/tasks.jsonld

Meanwhile, Bob has the same URL open:
  → Bob's store already subscribed to the same WebSocket
  → server broadcasts: pub https://pod.example/tasks.jsonld
  → Bob's store calls reload()
  → reload() fetches fresh data, re-indexes, calls onChange
  → Bob's UI re-renders — task shows as completed

Zero configuration. The store discovers WebSocket support from the server's response headers. If the server doesn't support it, everything still works — you just don't get live sync.

Local notifications (onChange)

Every store.set(), store.push(), store.remove(), store.unset(), and store.reorder() triggers all onChange listeners synchronously:

var store = createStore(data, { url: dataUrl, debounce: 800 })

// Subscribe — returns an unsubscribe function
var unsub = store.onChange(function() {
  renderApp()  // re-render the UI
})

// Every mutation triggers the callback
store.set(task, 'title', 'New title')  // → renderApp() fires
store.push(root, 'item', newItem)      // → renderApp() fires

// Unsubscribe when done
unsub()

The notification is synchronous — renderApp() runs inside store.set(), before set() returns. This means the UI is always in sync with the data. It also means you don't need to call renderApp() after mutations — it already ran.

Persistence (debounced PUT)

After a mutation, the store starts a debounce timer. If no more mutations happen within the debounce window, it PUTs the entire JSON-LD document to the URL:

var store = createStore(data, {
  url: 'https://pod.example/tasks.jsonld',
  authFetch: window.xlogin.authFetch,  // authenticated fetch
  debounce: 800                         // ms before saving
})

The timeline:

  1. store.set() at t=0 — marks dirty, starts 800ms timer
  2. store.set() at t=200 — resets timer to 800ms from now
  3. store.set() at t=500 — resets timer again
  4. t=1300 — no more mutations, timer fires, PUT request sent

This batches rapid changes (like typing) into a single save. If the save fails, the store stays dirty and retries on the next mutation.

WebSocket discovery

The store doesn't need a WebSocket URL configured. It discovers it automatically:

1. store.save() sends PUT to https://pod.example/tasks.jsonld
2. Server responds with header:
     Updates-Via: wss://pod.example/ws
3. Store connects to wss://pod.example/ws
4. Store sends: sub https://pod.example/tasks.jsonld
5. Server acknowledges subscription

This follows the Solid Protocol WebSocket specification. Any server that sends the Updates-Via header enables live sync automatically.

WebSocket reconnection

If the WebSocket disconnects, the store reconnects with exponential backoff:

Disconnect → wait 1s → reconnect
Disconnect → wait 2s → reconnect
Disconnect → wait 4s → reconnect
Disconnect → wait 8s → reconnect
...
Disconnect → wait 30s → reconnect (max)

On successful reconnect, the delay resets to 1s. This handles network interruptions, server restarts, and laptop lid close/open gracefully.

Remote notifications (pub/sub)

When another client saves to the same URL, the server broadcasts a pub message to all subscribers:

Server → all connected clients: pub https://pod.example/tasks.jsonld

Each client's store:
  → checks: am I currently dirty? (unsaved local changes)
  → if dirty: ignore (don't overwrite local work)
  → if clean: reload() → fetch fresh data → re-index → onChange → re-render

The dirty check prevents a conflict: if you're in the middle of editing and someone else saves, your local changes won't be overwritten. Your next save will PUT your version.

Note: This is last-write-wins, not CRDT merge. For true conflict resolution, use store-crdt.js which merges concurrent edits.

CRDT sync (store-crdt.js)

For applications that need concurrent editing without conflicts, LOSOS includes a CRDT store variant:

import { createStore } from './losos/store-crdt.js'

var store = createStore(data, { debounce: 500 })

// Listen for local operations
store.onOp(function(op) {
  // Send op to other clients (via WebSocket, WebRTC, etc.)
  ws.send(JSON.stringify(op))
})

// Apply operations from other clients
ws.onmessage = function(e) {
  store.applyRemote(JSON.parse(e.data))
  // → onChange fires → UI updates
}

Operations are commutative — they can arrive in any order and produce the same result. No central server required for merge logic.

Without a server

If you create a store without a URL, everything still works locally:

var store = createStore(data, { debounce: 500 })
// store.set() → onChange fires → UI updates
// No PUT, no WebSocket, no network
// Data lives in memory only

This is how the landing page demo and the example apps work — reactive UI with zero server infrastructure. Add a URL when you're ready to persist.


Summary

MechanismTriggerLatency
onChange (local)store.set()Synchronous (0ms)
PUT (persist)Debounce timer800ms default
WebSocket pub (remote)Server broadcast after PUT~50-200ms
reload (remote render)WebSocket pub received~100-300ms
ReconnectWebSocket disconnect1s–30s (exponential)