Building A Crdt-Backed Edit Feed With Per-Field Merge Using Lww-Registers
Written by
Maximus Arc
The bug that pushed me into “system design” land
I was building a tiny “edit history” page for a collaborative document. Users could edit the same item at the same time (title, tags, content). My first attempt used a simple “last write wins” strategy: whichever request arrived last overwrote the current state.
That worked… right up until it didn’t. Two users would update different fields, and the later request would stomp the other user’s changes. The UI would flicker and sometimes show older values reappearing.
What I needed was a merge strategy that could combine concurrent updates field-by-field, not object-by-object, while remaining consistent even when updates arrive out of order.
So I implemented a niche pattern: a CRDT-style edit feed where each document field is stored as a Last-Write-Wins (LWW) register, and the “edit feed” is a stream of those per-field updates.
The key idea: per-field LWW registers (CRDT-ish, but practical)
A CRDT (Conflict-free Replicated Data Type) is a data type designed so that replicas can accept updates in any order and still converge to the same result without needing centralized coordination.
A LWW register stores:
- a value
- a timestamp (or logical clock) When merging:
- keep the value with the highest timestamp
- in case of ties, break ties deterministically
Instead of storing “the whole document state” with one timestamp, I stored each field separately with its own timestamp. That way, concurrent updates to different fields don’t overwrite each other.
Data model: document with per-field registers
Here’s the state I modeled:
title: LWW registercontent: LWW registertags: LWW register (simple list replacement for this demo)
Each register tracks value, ts (timestamp), and actor (for deterministic tie-breaking).
Working example: in-memory merge + feed generation (Python)
I wanted something that behaves like a backend component: given a stream of edits, it can merge them into a consistent document state.
Below is a fully working implementation.
from __future__ import annotations from dataclasses import dataclass, asdict from typing import Any, Dict, List, Optional, Literal import time import json # ---------- CRDT-ish LWW register per field ---------- @dataclass(frozen=True) class LWWRegister: value: Any ts: float # timestamp: higher wins actor: str # deterministic tie-breaker when ts is equal def merge(self, other: "LWWRegister") -> "LWWRegister": # Higher timestamp wins; if equal, higher actor wins (lexicographic) if other.ts > self.ts: return other if other.ts < self.ts: return self return other if other.actor > self.actor else self @dataclass(frozen=True) class DocState: title: LWWRegister content: LWWRegister tags: LWWRegister def merge(self, other: "DocState") -> "DocState": # Merge each field independently. return DocState( title=self.title.merge(other.title), content=self.content.merge(other.content), tags=self.tags.merge(other.tags), ) def to_plain(self) -> Dict[str, Any]: return { "title": self.title.value, "content": self.content.value, "tags": self.tags.value, } # ---------- Edit feed: a stream of per-field updates ---------- EditOp = Literal["set_title", "set_content", "set_tags"] @dataclass(frozen=True) class EditEvent: doc_id: str op: EditOp value: Any ts: float actor: str def apply_event(state: DocState, ev: EditEvent) -> DocState: # Convert the event into a register update for exactly one field. reg = LWWRegister(value=ev.value, ts=ev.ts, actor=ev.actor) if ev.op == "set_title": return DocState(title=reg, content=state.content, tags=state.tags) if ev.op == "set_content": return DocState(title=state.title, content=reg, tags=state.tags) if ev.op == "set_tags": return DocState(title=state.title, content=state.content, tags=reg) raise ValueError(f"Unknown op {ev.op!r}") def build_initial_state() -> DocState: # Use deterministic baseline values with timestamp 0. base_ts = 0.0 return DocState( title=LWWRegister(value="", ts=base_ts, actor=""), content=LWWRegister(value="", ts=base_ts, actor=""), tags=LWWRegister(value=[], ts=base_ts, actor=""), ) # ---------- Demo: out-of-order delivery and concurrent edits ---------- def main() -> None: doc_id = "doc-123" actor_a = "alice" actor_b = "bob" # Initial backend snapshot state0 = build_initial_state() # Two users edit different fields concurrently. # We'll intentionally deliver events out of order. t1 = time.time() ev1 = EditEvent(doc_id=doc_id, op="set_title", value="CRDT Feed", ts=t1, actor=actor_a) t2 = t1 + 0.01 ev2 = EditEvent(doc_id=doc_id, op="set_content", value="Hello from Alice.", ts=t2, actor=actor_a) t3 = t2 + 0.02 ev3 = EditEvent(doc_id=doc_id, op="set_tags", value=["system-design", "crdt"], ts=t3, actor=actor_b) # Simulate out-of-order network delivery: # - backend receives: title (ev1), tags (ev3), content (ev2) feed_delivery_order = [ev1, ev3, ev2] # Approach A: apply in delivery order state_a = state0 for ev in feed_delivery_order: state_a = apply_event(state_a, ev) # Approach B: apply then merge from separately built replicas # Replica 1: Alice receives ev1 + ev2 replica1 = state0 for ev in [ev1, ev2]: replica1 = apply_event(replica1, ev) # Replica 2: Bob receives only ev3 replica2 = state0 replica2 = apply_event(replica2, ev3) # Merge replicas state_b = replica1.merge(replica2) print("=== Delivery order apply result ===") print(json.dumps(state_a.to_plain(), indent=2)) print("\n=== Replica merge result ===") print(json.dumps(state_b.to_plain(), indent=2)) assert state_a.to_plain() == state_b.to_plain(), "States diverged!" if __name__ == "__main__": main()
What happens when I run this
When I run it, both approaches produce the same final document:
titlebecomes “CRDT Feed” (from Alice)contentbecomes “Hello from Alice.” (from Alice)tagsbecomes["system-design", "crdt"](from Bob)
Even though set_tags arrived before set_content, nothing overwrote it incorrectly—because the merge decision is per-field, not per-document.
The subtle part: tie-breaking with actor
You might notice the merge() logic uses:
- higher
tswins - if
tsis equal, lexicographically largeractorwins
This matters because in real systems timestamps can collide (e.g., coarse clock resolution or batching). Deterministic tie-breaking guarantees all replicas converge to the same winner.
Mapping this to a “backend edit feed” architecture
In the real system, the edit feed typically looks like:
- Clients emit edit events (
set_title,set_content,set_tags) with timestamps and actor IDs. - Backend stores events in an append-only log (or a durable stream).
- Backend (or workers) maintains a materialized view: the current
DocState.
The key design choice is that the materialized view is built from per-field registers, so concurrency only affects the specific fields being edited.
Conceptually, the backend does:
- take incoming events
- apply them to the field they target
- keep the register with the winning
(ts, actor)pair
Where this helps (and where it doesn’t)
This pattern solved my flickering overwrite bug immediately. But it’s not magic for every data type.
- For simple scalar fields (title, status), LWW is great.
- For complex concurrent collections (reordering lists, partial edits to content), “replace the whole list” can lose intent.
- A different CRDT (like RGA, Logoot, LSEQ) is better for collaborative text.
- This post intentionally focuses on the per-field feed merge problem.
Conclusion
I learned that the simplest way to avoid “last request wins” stomping is to change the merge granularity: represent each field as its own LWW register and treat the edit feed as a stream of per-field operations. With a deterministic tie-breaker, the system converges even with out-of-order delivery—exactly what I needed for a collaborative edit history UI backed by a consistent materialized state.