Choosing Between LWW, Field-Union, and CRDT Merge

You have conflicting leaves piling up in CouchDB and a resolver to write, but the real decision is upstream of any code: which merge strategy actually fits the shape of your documents? Pick wrong and you either silently discard edits users expected to keep, or you bolt CRDT machinery onto a document that never needed it. This is a decision guide — it maps concrete data patterns to one of three strategies so you commit to the right one before writing a resolver. It sits directly under algorithm selection for merge and feeds the three implementation guides that follow. The rule that drives everything below: match the strategy to how your writers actually mutate the document, not to how sophisticated you wish the system were.

Decision tree for selecting a CouchDB conflict-merge strategy A top-down decision tree. The root node is a conflicted document with two or more leaf revisions. The first decision diamond asks whether discarding a concurrent write is acceptable for this document type. The yes edge points to a Last-Write-Wins leaf, labelled convergent but lossy, whose failure mode is clock skew inverting the winner. The no edge descends to a second decision diamond asking whether the conflicting branches mutate disjoint fields or accumulate into the same field. The disjoint-fields edge points to a Field-Union leaf, labelled lossless for non-overlapping edits, whose failure mode is two branches editing the same field. The accumulate edge points to a CRDT leaf, labelled lossless and order-independent, whose failure mode is unbounded replica-id growth and schema lock-in. Conflicted document 2+ leaf revisions on one _id Is discarding a concurrent write OK? yes no Last-Write-Wins convergent, lossy fails on: clock skew picks a stale winner Same field, or different fields? different fields same field accumulates Field-Union merge lossless if disjoint fails on: two branches editing the same field CRDT merge lossless, order-free fails on: replica-id growth, schema lock-in
The three-way decision: acceptable loss routes to Last-Write-Wins; lossless disjoint-field edits route to Field-Union; accumulating same-field state routes to a CRDT.

Prerequisites: Read Your Document Shape First

Before you choose, gather evidence about how the document actually changes. The strategy follows from the write pattern, and that pattern is observable.

  • Sample the conflicts you already have. Pull a handful of live conflicts and diff the losing leaves against the winner: curl 'http://localhost:5984/appdb/{id}?conflicts=true', then GET ?rev= each entry. If the branches differ on one field, you have a disjoint-edit pattern; if they both bump the same numeric field, you have accumulation.
  • Classify the writer intent. Ask who writes the field and why. A single owning device reporting the latest sensor reading is convergent overwrite. A config document where the firmware team edits one key and the network team edits another is disjoint. A like-count or an offline shopping cart is accumulation.
  • Check for an application timestamp. LWW is only safe when every writer stamps a trustworthy updated_at; _rev ordering is topological, never chronological, as revision tree mechanics explains. No reliable clock rules LWW out immediately.
  • Environment. Python 3.10+ and requests. The dispatcher below is pure routing logic and needs no live database to run, so you can validate the decision offline first.

The Decision Matrix

Map your observed pattern to a strategy using the table. Each row is a data shape you can recognise from the samples above; the columns are the trade-offs you are committing to.

Data pattern Strategy Consistency Write latency Complexity Failure mode
High-churn telemetry, single-owner sensor state, cache-like values Last-Write-Wins Convergent, lossy Lowest (one write) Low Clock skew promotes a stale write; the loser is gone
Config/profile docs whose branches edit different keys Field-Union Convergent, lossless for disjoint fields Low (read leaves + one batch) Medium Two branches edit the same field → still needs a tiebreak
Counters, sets, tag lists, offline-accumulated tallies CRDT (state-based) Strong eventual, lossless Medium (per-replica structure) High Replica-id sprawl, schema lock-in, integer overflow
Free-form collaborative text or ordered lists CRDT (sequence type) Strong eventual, lossless Highest (op metadata) Very high Metadata outgrows payload; usually offload to a dedicated library

The matrix collapses to one question per row: is loss acceptable (LWW), is the conflict spatially disjoint across fields (Field-Union), or does the value accumulate regardless of order (CRDT)? Answer that and the resolver writes itself.

“If Your Docs Look Like X, Pick Y”

Concrete recognition rules beat abstract properties. Hold each pattern against your sampled documents.

  • If your document is a latest-value report — a sensor emitting {"reading": 21.4, "updated_at": ...} where only the newest value matters and older readings are noise — pick Last-Write-Wins. Follow implementing Last-Write-Wins in CouchDB.
  • If your document is a settings bag{"firmware_version": ..., "network_config": ..., "display_name": ...} where different actors own different keys and two branches almost never touch the same key — pick Field-Union. Follow implementing Field-Union merge in CouchDB.
  • If your document is a tally or a set — a like-counter, an inventory decrement, an offline cart where two devices each add items and both additions must survive — pick a CRDT. Follow implementing CRDT counters in CouchDB.
  • If your document mixes shapes — a latest-value block and a counter in the same _id — split the fields by strategy inside one resolver rather than forcing a single algorithm across the whole document. The dispatcher below is built for exactly that routing.

Complete Working Example

The dispatcher routes each conflicted document to the strategy its doc_type demands. It keeps strategy selection in one auditable place so a new document class is a table entry, not a scattered if branch. The three resolver bodies are stubs here that stand in for the full implementations linked above; the routing, precondition checks, and fallback are complete and runnable.

import os
from datetime import datetime, timezone

import requests

# doc_type -> strategy name. This registry IS the decision; keep it declarative.
STRATEGY_BY_TYPE = {
    "sensor_reading": "lww",
    "device_cache": "lww",
    "device_config": "field_union",
    "user_profile": "field_union",
    "like_counter": "crdt",
    "inventory_tally": "crdt",
}


def parse_ts(value: str) -> datetime:
    """Parse an app timestamp as timezone-aware UTC (LWW safety guard)."""
    dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
    return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)


def fetch_branches(db_url: str, doc_id: str) -> tuple[dict, list[dict]]:
    """Return (winner_body, [winner, *losers]) for a conflicted document."""
    winner = requests.get(f"{db_url}/{doc_id}", params={"conflicts": "true"}).json()
    branches = [winner]
    for rev in winner.get("_conflicts", []):
        branches.append(requests.get(f"{db_url}/{doc_id}", params={"rev": rev}).json())
    return winner, branches


def resolve_lww(winner, branches):
    """Newest updated_at wins; ties break on highest _rev. (Lossy.)"""
    chosen = max(branches, key=lambda d: (parse_ts(d.get("updated_at", "1970-01-01Z")), d["_rev"]))
    return {k: v for k, v in chosen.items() if not k.startswith("_")}


def resolve_field_union(winner, branches):
    """Keep each field from whichever branch stamped it most recently."""
    merged, field_ts = {}, {}
    for b in branches:
        stamp = b.get("field_updated_at", {})
        for key, value in b.items():
            if key.startswith("_") or key == "field_updated_at":
                continue
            ts = stamp.get(key, b.get("updated_at", "1970-01-01Z"))
            if key not in field_ts or ts > field_ts[key]:
                merged[key], field_ts[key] = value, ts
    return merged


def resolve_crdt(winner, branches):
    """Element-wise max of per-replica sub-counters across all leaves."""
    p, n = {}, {}
    for b in branches:
        for node, v in b.get("p", {}).items():
            p[node] = max(p.get(node, 0), v)
        for node, v in b.get("n", {}).items():
            n[node] = max(n.get(node, 0), v)
    return {"p": p, "n": n, "value": sum(p.values()) - sum(n.values())}


RESOLVERS = {"lww": resolve_lww, "field_union": resolve_field_union, "crdt": resolve_crdt}


def select_strategy(doc_type: str) -> str:
    """Route a document class to its merge strategy, defaulting to review."""
    return STRATEGY_BY_TYPE.get(doc_type, "manual_review")


def resolve(db_url: str, doc_id: str) -> dict:
    winner, branches = fetch_branches(db_url, doc_id)
    if not winner.get("_conflicts"):
        return {"ok": True, "note": "no conflict"}
    strategy = select_strategy(winner.get("doc_type", ""))
    if strategy == "manual_review":
        return {"ok": False, "route": "manual_review", "doc_type": winner.get("doc_type")}
    merged = RESOLVERS[strategy](winner, branches)
    return {"ok": True, "strategy": strategy, "merged_preview": merged}


if __name__ == "__main__":
    url = os.environ.get("COUCH_URL", "http://localhost:5984/appdb")
    doc = os.environ.get("DOC_ID", "device-42")
    print(resolve(url, doc))

The dispatcher deliberately defaults unknown doc_type values to manual_review rather than guessing a strategy — an unrouted document is a decision you have not made yet, and forcing LWW onto it is how silent data loss enters production.

Gotchas & Edge Cases

  • LWW’s convergence is not safety. Every strategy here converges; only LWW converges by throwing an edit away. “It stops conflicting” is not evidence it kept the right data. Reserve it for values you can afford to lose.
  • Field-Union is only lossless while edits stay disjoint. The moment two branches write the same key, Field-Union needs the same timestamp or _rev tiebreak LWW uses — and it becomes lossy for that field. Audit your real conflict distribution before assuming disjointness.
  • CRDTs change your schema permanently. A counter stored as {"p": {...}, "n": {...}} cannot be read by code expecting a plain integer. Adopting a CRDT is a data-model commitment across every reader, not a resolver you can swap out later.
  • Do not mix LWW and CRDT on the same document. An LWW overwrite of a CRDT field discards other replicas’ increments — the exact loss the CRDT existed to prevent. Partition fields by strategy or split them into separate documents.
  • The choice can be per-field, not per-document. A single _id may hold an LWW status field and a CRDT counter. Route inside the resolver, as the dispatcher does, rather than picking one algorithm for the whole document.

Verification & Observability

Validate the decision, not just the code. First, replay your sampled real conflicts through the dispatcher offline and confirm each doc_type lands on the strategy you intended and that nothing falls through to manual_review unexpectedly. Second, once resolvers run live, watch the per-document ?conflicts=true array clear and track a per-strategy resolution counter — a spike in the LWW counter for a doc_type you meant to route to Field-Union means a registry gap. Third, alert on the manual_review rate: a rising tail of unrouted document types is your signal to extend STRATEGY_BY_TYPE before the backlog grows. When a document genuinely resists all three strategies, route it through fallback resolution chains rather than defaulting to a lossy write.

FAQ

Can I start with Last-Write-Wins and migrate to a CRDT later if I need it?

You can, but it is a schema migration, not a config change. LWW stores a plain value; a CRDT stores per-replica sub-structures. Migrating means rewriting every document into the CRDT shape and updating every reader that expected the scalar. It is far cheaper to classify the document correctly up front using the matrix than to reshape live data after loss has already occurred. Reserve LWW for values where that loss is genuinely acceptable.

What if different fields in one document need different strategies?

That is common and fully supported — route per field inside a single resolver. A device document might resolve its status field by Last-Write-Wins and its error_count field as a CRDT counter in the same pass. The dispatcher pattern above generalises to this: dispatch on the field’s declared strategy rather than the whole document’s doc_type. Never let one algorithm overwrite a field that another strategy owns.

Is Field-Union just Last-Write-Wins applied per field?

For disjoint edits, effectively yes — each field keeps the value from the branch that last touched it, so non-overlapping edits all survive where whole-document LWW would drop the losing branch entirely. The difference appears when two branches edit the same field: there Field-Union degrades to a per-field LWW tiebreak and becomes lossy for that field only. Field-Union is the lossless choice precisely when your conflicts are spatially separated across the document.

Part of: Algorithm Selection for Merge