How Differential JSON Kills the Polling vs Push Tradeoff
I spent years building real-time systems the hard way -- polling databases, wiring up pub/sub, watching bandwidth bills climb. The fundamental problem is always the same: how do you keep N clients in sync with server state without either wasting bandwidth (polling) or overcommitting on infrastructure (push)? The answer I landed on is differential synchronization built on an extended JSON Merge Patch, and it turns out to be simpler and cheaper than either alternative.
This is the story of how Adama's delta protocol works, why RFC 7386 is almost but not quite good enough, and the specific tricks that make per-viewer deltas practical.
Every real-time system faces the same choice. You can poll: clients ask "what's the current state?" on a timer. Or you can push: the server broadcasts every change to every subscriber. Both have well-understood failure modes.
Polling is simple and resilient -- you ask for data, you get data, there is no mystery about where things stand. But polling wastes bandwidth sending unchanged data, wastes CPU re-rendering identical state, and imposes a latency floor equal to your polling interval. If your document has 10,000 records and one changed, you still transfer all 10,000.
Push (typically via pub/sub) eliminates the latency problem but introduces a quadratic complexity issue I will cover in a separate report. More immediately, push creates reliability nightmares: what happens when the client disconnects and reconnects? What messages were lost? The messaging stack either provides delivery guarantees (expensive) or it doesn't (your problem).
The delta approach sidesteps this tradeoff entirely. The server maintains the full document state. When something changes, it computes the minimal JSON diff for each connected client -- accounting for what that specific client can see -- and sends only the diff. If nothing changed for a particular viewer, nothing is sent. If the document has 10,000 records and one changed, the delta is roughly 100 bytes, not 1 MB.
And here is the critical insight: if the client disconnects and reconnects, you can simply send the full state again and the client is back in sync. That is the resilience of polling, combined with the efficiency of push, without the complexity of either.
JSON Merge Patch (RFC 7386) is an elegant spec. You take two JSON objects, compute the diff, and the result is a new JSON object that when merged with the original produces the updated state. Set a field to null and it is deleted. Nest objects and the merge recurses. It forms something close to an algebraic group on the set of all JSON objects, which is the kind of mathematical property that signals a useful substrate.
Here is a simple example. The server state changes from:
{"score": 0, "status": "waiting", "active": true}
to:
{"score": 42, "status": "playing", "active": true}
The delta is:
{"score": 42, "status": "playing"}
The client merges this into its local copy. active is untouched because it did not change. Nested objects work the same way -- if player.health changes but player.name does not, the delta is {"player": {"health": 75}}.
This is clean and simple for objects. But RFC 7386 has a gap: arrays.
If you have an array [1, 5, 6, 7] and insert an element to get [1, 5, 100, 6, 7], the merge patch treats the entire array as a single value. You send the whole array. For a 500-element list where one element changes, that is a problem.
Adama solves this by transforming arrays of objects into objects with a special ordering field. The key insight: if each element has a unique integer ID, you can represent the array as a map keyed by ID plus an @o field that specifies order.
An array like:
[{"id": 42, "name": "Jeff"}, {"id": 50, "name": "Jake"}, {"id": 66, "name": "Bob"}]
becomes:
{
"42": {"name": "Jeff"},
"50": {"name": "Jake"},
"66": {"name": "Bob"},
"@o": ["42", "50", "66"]
}
Now RFC 7386 just works on the individual elements. Changing Jeff's name to Jeffrey produces:
{"42": {"name": "Jeffrey"}}
No @o field in the delta because the order did not change. Adding an element includes the new entry plus the updated @o. Removing an element just updates @o -- the client prunes anything not in the ordering list.
Reordering is just a new @o with zero data changes. That matters for things like leaderboards where the underlying records are unchanged but their display order shifts constantly.
There is a further optimization for the @o array itself. If you have a 100-element list and insert one item at position 5, sending all 101 IDs is wasteful. Adama uses a heterogeneous encoding where contiguous unchanged subsequences are represented as index ranges. An insert at position 5 in a 10-element list becomes:
[[0, 4], 100, [5, 9]]
Three elements instead of eleven. For large lists with small changes, the savings are substantial.
This is where the delta protocol becomes more than just a bandwidth optimization. In a card game, Alice should see her own cards but not Bob's. In Adama, this is declared directly in the data model:
record Card {
public int id;
private principal owner;
viewer_is<owner> int value;
}
The viewer_is<owner> modifier means the value field is only visible to the principal stored in owner. When the runtime computes deltas, it runs the privacy filter for each connected viewer. Alice sees {id: 1, value: 7} for her card. Bob sees {id: 1} -- the value field simply is not there.
The efficiency implications compound. When Alice's card value changes from 7 to 9, the delta to Alice is {"cards": {"1": {"value": 9}}}. The delta to Bob is {} -- an empty object. Nothing changed in Bob's view, so nothing is sent. The server does not waste bandwidth telling Bob about changes he cannot see.
This is privacy and efficiency working as a single mechanism rather than two separate systems competing for resources.
Each delta carries an @s field -- an incrementing sequence number:
{"@s": 42, "score": 100}
Clients track the last sequence number they received. On reconnect, the simplest strategy is to download a fresh snapshot and locally diff it against the cached state to produce UI update events. This sounds expensive, but the document state is typically small (kilobytes to low megabytes), and the reconnect path is the rare case.
The important thing is that reconnection is conceptually identical to polling: you ask for the current state, you get the current state. There is no gap negotiation, no replay buffer, no complex resume protocol. The WebSocket is a downstream optimization over polling, not a fundamentally different programming model.
This is what I mean when I say the delta protocol combines the resilience of polling with the efficiency of push. When things go wrong -- and they will, networks are hostile -- the fallback is just "fetch the document again." That is the request-response resilience at play.
When clients are slow or the network is congested, deltas naturally batch. If three state changes happen before the previous delta is acknowledged, the server computes a single delta covering all three changes. This is a consequence of the differential approach: you are always computing "what is different between what the client has and what the server has," not "here is a stream of individual events you must process in order."
Traditional pub/sub systems face catastrophic queuing under back-pressure because each event is an independent unit. Drop one and you lose data. Queue them all and you run out of memory. With deltas, the server's memory usage approaches the size of a single poll response -- the current view for each client. Under pressure, deltas get larger (covering more changes) rather than more numerous (more events to queue).
This is also why the delta approach handles deployment and server restarts cleanly. When the server comes back, clients reconnect and get a fresh snapshot. There are no persistent queues to drain, no offsets to manage, no message ordering to reconstruct.
Nothing is free. The delta protocol has real tradeoffs.
First, per-viewer delta computation is CPU work. For each connected client, the server must evaluate privacy policies and diff against the previous view. With thousands of concurrent viewers on a single document, this becomes a bottleneck. The practical limit is low thousands of connected clients per document.
Second, the server must maintain the previous view state for each connection. That is memory proportional to (number of clients) times (average view size). For most applications this is fine. For a broadcast scenario with 100,000 viewers, you would need to shard.
Third, the @o array encoding adds complexity to client-side merge code. Every client library needs to understand the ordering format and handle the heterogeneous range encoding. This is a one-time implementation cost, but it is real -- and it is a surface area for bugs.
Fourth, deterministic replay requires careful handling of non-determinism. Random number generators are document-scoped and persisted so that replaying the event log produces identical state. This is elegant but imposes constraints on what operations are available in the language.
Despite these costs, I have found the delta protocol to be the right tradeoff for the class of applications Adama targets: multiplayer games, collaborative tools, real-time dashboards -- anywhere 2 to 2,000 people share state and privacy matters. The bandwidth savings alone typically justify the CPU overhead, and the simplicity of the reconnection model eliminates an entire category of infrastructure (message brokers, persistent queues, delivery guarantee systems) that you would otherwise need to build or buy.