The Delta Protocol
The delta protocol is how Adama keeps connected clients in sync with document state. Instead of sending complete state snapshots on every change, it computes and transmits only what changed -- per viewer, accounting for privacy. This is the mechanism that makes real-time work at scale, and understanding it helps you design schemas that sync efficiently and debug the cases where things go sideways.
How Deltas Work Conceptually
When a client connects to a document, a synchronization relationship begins:
- Initial Sync: The client receives a complete JSON snapshot of their personalized view
- Change Detection: When document state changes, the runtime detects what's different
- Delta Computation: For each viewer, Adama computes the minimal delta representing their view's changes
- Transmission: Deltas go to clients over WebSocket connections
- Client Merge: Clients apply deltas to their local state
This cycle repeats for the lifetime of the connection. It's simple in concept; the magic is in making it efficient.
The State Lifecycle
Document Change
|
v
+-------------------+
| Compute Deltas |---- For each connected viewer
+-------------------+
|
v
+-------------------+
| Apply Privacy |---- Filter based on viewer policies
+-------------------+
|
v
+-------------------+
| Serialize JSON |---- Create minimal delta message
+-------------------+
|
v
+-------------------+
| Send to Client |---- Over WebSocket
+-------------------+
|
v
+-------------------+
| Client Merges |---- Apply delta to local state
+-------------------+
Why This Matters
Consider a chat application with 10,000 messages. Without deltas, every new message would require sending all 10,000 messages to every client. With deltas:
| Approach | Data per new message |
|---|---|
| Full sync | 10,000 messages (~1MB) |
| Delta sync | 1 message (~100 bytes) |
The difference is enormous. Multiply by a hundred connected clients and frequent updates, and you start to see why delta-based sync isn't optional -- it's the only approach that works.
Delta Message Format
Adama uses an extended JSON Merge Patch format (based on RFC 7386 ) with custom handling for arrays.
Simple Field Updates
For scalar fields, the delta contains only the changed values:
public int score = 0;
public string status = "waiting";
public bool active = true;
Initial state sent to client:
{"score": 0, "status": "waiting", "active": true}
After score = 42; status = "playing";:
{"score": 42, "status": "playing"}
Notice that active isn't in the delta because it didn't change. The client merges this delta with their existing state -- overwrite the fields that are present, leave everything else alone.
Nested Object Updates
For records and nested structures, deltas follow the same pattern:
record Player {
public string name;
public int health;
public int mana;
}
public Player player;
Initial state:
{"player": {"name": "Alice", "health": 100, "mana": 50}}
After player.health = 75;:
{"player": {"health": 75}}
The delta includes only the path to the changed field. The client merges {"health": 75} into their existing player object. Name and mana are untouched.
Array Handling with @o
Standard JSON Merge Patch can't express array modifications well. Adama extends the format with @o (ordering):
record Item {
public int id;
public string name;
}
table<Item> items;
Initial state (3 items):
{
"items": {
"@o": ["1", "2", "3"],
"1": {"id": 1, "name": "Sword"},
"2": {"id": 2, "name": "Shield"},
"3": {"id": 3, "name": "Potion"}
}
}
The @o array specifies which elements exist and their order. Each element is keyed by its string ID.
After adding item 4:
{
"items": {
"@o": ["1", "2", "3", "4"],
"4": {"id": 4, "name": "Helmet"}
}
}
The delta includes the new @o array and only the new item. Existing items (1, 2, 3) aren't re-sent.
After deleting item 2:
{
"items": {
"@o": ["1", "3", "4"]
}
}
Only the ordering changes. The client removes any item not in @o from their local state.
After reordering (reverse order):
{
"items": {
"@o": ["4", "3", "1"]
}
}
Just the @o array changes; item data stays the same.
After updating item 3's name:
{
"items": {
"3": {"name": "Health Potion"}
}
}
When an item updates without order change, @o isn't included. Only the changed data ships.
The @s Sequence Number
Each delta includes a sequence number for ordering:
{
"@s": 42,
"score": 100
}
The @s field is an incrementing counter that helps clients:
- Detect missed deltas
- Ensure deltas are applied in order
- Handle reconnection and resync
Null for Deletion
To remove a field (as opposed to updating it), the value is set to null:
public maybe<string> nickname;
After setting nickname:
{"nickname": "TheChampion"}
After clearing nickname:
{"nickname": null}
The client removes the nickname field from their local state.
Privacy Filtering in Deltas
Deltas are computed separately for each viewer based on privacy policies. This is one of the things I'm most pleased with in the design -- privacy isn't bolted on, it's woven into the delta computation itself.
Per-Viewer Views
Consider a card game where each player sees only their own cards:
record Card {
public int id;
private principal owner;
viewer_is<owner> int value;
}
table<Card> cards;
When card 1 (owned by Alice, value 7) and card 2 (owned by Bob, value 3) exist:
Alice's view:
{
"cards": {
"@o": ["1", "2"],
"1": {"id": 1, "value": 7},
"2": {"id": 2}
}
}
Bob's view:
{
"cards": {
"@o": ["1", "2"],
"2": {"id": 2, "value": 3},
"1": {"id": 1}
}
}
Both see both cards, but only their own card's value. The delta system handles all of this automatically.
Delta Privacy Implications
When Alice's card value changes from 7 to 9:
Delta to Alice:
{"cards": {"1": {"value": 9}}}
Delta to Bob:
{}
Bob receives no delta because from his perspective, nothing changed. His view of card 1 never included the value. This is exactly right -- and it means the system leaks zero information about state changes that a viewer can't see.
Record-Level Privacy
With require policies, entire records can be invisible:
record Secret {
public int id;
private principal owner;
public string data;
policy is_owner { return @who == owner; }
require is_owner;
}
table<Secret> secrets;
If there are secrets for Alice, Bob, and Carol:
Alice's view:
{
"secrets": {
"@o": ["1"],
"1": {"id": 1, "data": "Alice's secret"}
}
}
Alice only sees her own secret. Bob and Carol's secrets don't exist in her view at all -- not hidden, not redacted, simply absent.
Policy Changes
When a policy condition changes, deltas reflect the new visibility:
record Message {
public int id;
public string content;
public bool published;
policy is_visible { return published; }
require is_visible;
}
table<Message> messages;
Before publishing (user sees nothing):
{"messages": {"@o": []}}
After published = true:
{
"messages": {
"@o": ["1"],
"1": {"id": 1, "content": "Hello world", "published": true}
}
}
The message "appears" to the viewer when it becomes visible. From the client's perspective, it's as if the record was just created.
Efficient State Sync
The delta protocol is designed for efficiency at multiple levels.
Batching Updates
All changes within a single transaction are combined into one delta:
message GameUpdate {
}
public int score = 0;
public int level = 1;
public int lives = 3;
public string status = "waiting";
channel updateGame(GameUpdate msg) {
score += 10;
level++;
lives = 3;
status = "playing";
}
This produces one delta, not four:
{
"@s": 15,
"score": 110,
"level": 5,
"lives": 3,
"status": "playing"
}
Differential Computation
The runtime maintains the previous view for each client and compares to compute minimal deltas:
Previous View: {"x": 1, "y": 2, "z": 3}
Current View: {"x": 1, "y": 5, "z": 3}
Delta: {"y": 5}
Only actual differences are transmitted. If you set a field to its current value, no delta is generated for that field.
Structural Sharing
Complex nested structures share unchanged portions:
record Config {
public Settings general;
public Settings advanced;
}
record Settings {
public int value1;
public int value2;
public int value3;
}
If only config.general.value1 changes, the delta is:
{"config": {"general": {"value1": 42}}}
The advanced settings and other general values aren't included.
Connection-Level Optimization
Each connection maintains its own sync state:
- View state: The client's current view variables
- Previous snapshot: What the client last saw
- Pending deltas: Changes not yet acknowledged
This allows the server to compute exactly what each client needs without redundant work.
Client-Side Delta Application
Clients must correctly apply deltas to maintain consistent state. Here's the algorithm.
Basic Merge Algorithm
function applyDelta(current, delta) {
for (const key in delta) {
if (key === '@s') continue; // Sequence number, not data
const value = delta[key];
if (value === null) {
// Deletion
delete current[key];
} else if (typeof value === 'object' && !Array.isArray(value)) {
// Nested object - check for array (@o)
if ('@o' in value) {
current[key] = applyArrayDelta(current[key] || {}, value);
} else {
// Regular object merge
current[key] = current[key] || {};
applyDelta(current[key], value);
}
} else {
// Scalar replacement
current[key] = value;
}
}
return current;
}
Array Delta Application
function applyArrayDelta(current, delta) {
// Apply updates to existing/new items
for (const key in delta) {
if (key === '@o') continue;
if (delta[key] === null) {
delete current[key];
} else {
current[key] = current[key] || {};
applyDelta(current[key], delta[key]);
}
}
// Apply ordering if present
if ('@o' in delta) {
const newOrder = delta['@o'];
const ordered = [];
// Remove items not in new order
for (const key in current) {
if (key !== '@o' && !newOrder.includes(key)) {
delete current[key];
}
}
current['@o'] = newOrder;
}
return current;
}
Converting to Arrays
For UI rendering, convert the object format to arrays:
function toArray(objWithOrder) {
const order = objWithOrder['@o'] || [];
return order.map(id => objWithOrder[id]).filter(Boolean);
}
// Usage
const itemsObject = state.items;
const itemsArray = toArray(itemsObject); // Ready for rendering
Handling Reconnection
When a client reconnects, they may have missed deltas:
- Client sends their last known sequence number
- Server checks if it can send incremental deltas
- If not (too many missed), server sends full resync
- Client replaces state with new snapshot
function handleMessage(message) {
if (message.type === 'delta') {
if (message['@s'] === expectedSequence) {
applyDelta(state, message);
expectedSequence++;
} else {
// Missed deltas, request resync
requestResync();
}
} else if (message.type === 'sync') {
// Full state replacement
state = message.state;
expectedSequence = message['@s'] + 1;
}
}
Debugging Delta Issues
When state sync goes wrong, systematic debugging is your friend. I've chased enough of these bugs to have a process.
Common Symptoms
| Symptom | Likely Cause |
|---|---|
| Client shows stale data | Delta not applied, missed delta |
| Data appears then disappears | Privacy policy flapping |
| Array order wrong | @o not being processed |
| Fields missing | Null delta not handled |
| Duplicate entries | ID collision, merge error |
Enable Delta Logging
Log deltas on the client to see exactly what's coming in:
connection.onDelta = (delta) => {
console.log('Delta received:', JSON.stringify(delta, null, 2));
applyDelta(state, delta);
};
This is always my first step. See the raw deltas, understand what the server thinks it's sending.
Verify Server-Side View
Use the CLI or API to inspect what the server thinks a viewer should see:
# Get current view for a specific viewer
adama document view --space myspace --key mydoc --who "user:alice"
Compare this with what the client has locally. If they match, your delta application is fine and the bug is elsewhere. If they don't match, you've got a merge issue.
Check Privacy Policies
If data isn't visible when expected, verify the privacy policy:
record Secret {
private principal owner;
public string debug_owner; // Temporarily expose for debugging
policy can_see {
// Add logging or simplify to isolate issue
return @who == owner;
}
}
Inspect Formula Dependencies
If derived values aren't updating, check formula dependencies:
public int a = 1;
public int b = 2;
public formula sum = a + b;
// Is 'sum' in the client's subscribed view?
// Is the formula being invalidated when a or b changes?
Sequence Number Gaps
If clients fall out of sync, check for sequence gaps:
let lastSequence = -1;
connection.onDelta = (delta) => {
const seq = delta['@s'];
if (lastSequence !== -1 && seq !== lastSequence + 1) {
console.warn(`Sequence gap: expected ${lastSequence + 1}, got ${seq}`);
}
lastSequence = seq;
};
Network Issues
Delta problems can stem from network issues:
- High latency: Deltas arrive late, UI feels sluggish
- Packet loss: Deltas missed, requires resync
- Connection drops: Full resync needed on reconnect
Monitor connection health:
connection.onDisconnect = () => {
console.log('Connection lost, will resync on reconnect');
};
connection.onReconnect = () => {
console.log('Reconnected, awaiting resync');
};
Advanced Delta Patterns
Optimistic Updates
Clients can apply changes optimistically before server confirmation:
function sendMessage(content) {
// Optimistic local update
const tempId = 'temp-' + Date.now();
localState.messages[tempId] = {content, pending: true};
// Send to server
connection.send('addMessage', {content});
}
connection.onDelta = (delta) => {
applyDelta(state, delta);
// Remove temp messages when real ones arrive
for (const id in state.messages) {
if (id.startsWith('temp-') && !state.messages[id].pending) {
delete state.messages[id];
}
}
};
Delta Compression
For high-frequency updates, consider client-side delta compression:
let pendingDelta = null;
let applyTimeout = null;
connection.onDelta = (delta) => {
// Merge into pending
pendingDelta = mergeDelta(pendingDelta || {}, delta);
// Debounce application
clearTimeout(applyTimeout);
applyTimeout = setTimeout(() => {
applyDelta(state, pendingDelta);
pendingDelta = null;
render();
}, 16); // ~60fps
};
View State Optimization
Minimize view state changes to reduce delta computation:
// Wasteful: Updates view on every scroll pixel
window.onscroll = () => {
connection.updateView({scrollY: window.scrollY});
};
// Better: Debounce and quantize
let lastReportedScroll = 0;
window.onscroll = debounce(() => {
const quantized = Math.floor(window.scrollY / 100) * 100;
if (quantized !== lastReportedScroll) {
connection.updateView({scrollY: quantized});
lastReportedScroll = quantized;
}
}, 100);
The delta protocol is designed to be invisible in normal operation -- clients see their state update in real time and never think about it. But when you're building complex applications, or when something's not working the way you expect, understanding these internals is what lets you reason about the problem instead of just staring at it.