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:
store.set()at t=0 — marks dirty, starts 800ms timerstore.set()at t=200 — resets timer to 800ms from nowstore.set()at t=500 — resets timer again- 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.
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
| Mechanism | Trigger | Latency |
|---|---|---|
| onChange (local) | store.set() | Synchronous (0ms) |
| PUT (persist) | Debounce timer | 800ms default |
| WebSocket pub (remote) | Server broadcast after PUT | ~50-200ms |
| reload (remote render) | WebSocket pub received | ~100-300ms |
| Reconnect | WebSocket disconnect | 1s–30s (exponential) |