CRDTs and Coordination Avoidance: Counters, Registers, and Last-Writer-Wins Trade-Offs

LESSON

CRDTs and Coordination Avoidance

007 30 min intermediate

CRDTs and Coordination Avoidance: Counters, Registers, and Last-Writer-Wins Trade-Offs

Core Insight

A replicated application rarely stores only one kind of fact. A shared project board might store a reaction count, a task title, an assignee, and a "last viewed at" time. All of those look like small fields, but they do not merge the same way.

A counter answers "how much?" If two replicas both increment a value, the natural merge should keep both increments. A register answers "which value?" If two replicas write different task titles at the same time, the merge needs a policy for what to show. Last-writer-wins, often shortened to LWW, is one such policy: keep the value with the greatest timestamp or comparable ordering key.

The easy mistake is to treat LWW as a universal conflict resolver. It is simple and deterministic, but it can silently discard a concurrent write. That may be fine for a cache timestamp or a temporary status indicator. It is risky for a shopping cart item, an address change, or any field where losing a user's update would be surprising.

The core design move is to match the CRDT shape to the meaning of the data. Counters preserve independent numeric contributions. Multi-value registers preserve concurrent alternatives. LWW registers choose one value and accept the loss of the others. The trade-off is simplicity versus information preservation.

Counters: Additive Facts

Start with the friendliest case: a value where independent changes should accumulate.

Imagine a replicated "likes" counter. Region A receives three likes while Region B receives two likes during a network partition. After the partition heals, the correct value is five. Neither side should overwrite the other.

A grow-only counter, or G-counter, keeps one component per replica:

state at A:
  A -> 3
  B -> 0

state at B:
  A -> 0
  B -> 2

The value is the sum of all components:

value = A + B

The merge takes the maximum for each component:

join({A:3, B:0}, {A:0, B:2})
  = {A:3, B:2}

value = 5

The maximum matters because each component only moves upward. If the same state arrives twice, the second merge does not double-count it. If messages arrive out of order, the largest known component still wins for that replica.

A positive-negative counter, or PN-counter, adds support for decrements by keeping two grow-only counters:

P: increments by replica
N: decrements by replica

value = sum(P) - sum(N)

This is useful when a metric can go up and down. It does not automatically protect every business rule. If inventory must never go below zero, a plain PN-counter is not enough. Two regions can both decrement locally and only discover later that the combined value crossed the limit. That bounded case needs rights, escrow, or coordination, which appears later in the track.

The simple rule: counters are good when the domain wants independent numeric contributions to add together.

Registers: One Slot, Harder Choices

A register stores a value in one logical slot:

task.title = "Book venue"

The hard part is concurrent writes. Suppose two replicas are offline:

initial title:
  "Book venue"

Ana writes at A:
  "Book main venue"

Bruno writes at B:
  "Book backup venue"

When A and B synchronize, both writes are real. Neither writer observed the other. A counter can combine independent changes by summing. A register cannot combine these two strings without a policy.

There are three common choices:

multi-value register:
  keep both values and expose the conflict

last-writer-wins register:
  keep one value using a timestamp or ordering key

domain-specific merge:
  apply application logic, such as merging structured fields

A multi-value register is honest. It preserves the fact that there are concurrent values:

task.title =
  "Book main venue"
  "Book backup venue"

That may be exactly what a storage system should do. It avoids data loss, but it pushes resolution to the application or user experience. Someone must decide how to present the conflict and how a later write resolves it.

Last-Writer-Wins

Last-writer-wins chooses one value by comparing metadata. A simple LWW register stores:

value
timestamp_or_order
tie_breaker

Merge keeps the value with the largest ordering key:

write at A:
  value = "Book main venue"
  timestamp = 10:03:02
  replica = A

write at B:
  value = "Book backup venue"
  timestamp = 10:03:05
  replica = B

LWW result:
  "Book backup venue"

The result is deterministic. Every replica that sees both writes will choose the same value, assuming they use the same comparison rule.

That simplicity is the appeal. LWW has a small payload, a simple merge, and an easy mental model for fields where older values genuinely do not matter. A "last seen online" field is a good example. If one replica reports that a user was seen at 10:03 and another reports 10:05, keeping 10:05 is usually reasonable.

The danger is that LWW can turn concurrency into accidental deletion. In the task title example, B's write did not supersede A's write in a human sense. Bruno did not read Ana's title and choose to replace it. The system merely picked B because B's ordering key was larger.

Wall clocks make this sharper. If A's clock is slow or B's clock is fast, "latest timestamp" may not mean "latest observed decision." Hybrid logical clocks or server-assigned timestamps can reduce some clock problems, but they do not change the policy: LWW still discards concurrent alternatives.

The central trade-off is clear:

LWW gives:
  small metadata
  one visible value
  deterministic convergence

LWW costs:
  possible lost updates
  clock or ordering assumptions
  less evidence for user-facing conflict resolution

Choosing A Field Policy

A practical CRDT design starts by asking what kind of fact the field represents.

Use a counter when independent numeric changes should accumulate:

likes
download counts
local increments to a metric

Use a multi-value register when losing concurrent writes is worse than showing a conflict:

shipping address
document title
workflow owner
configuration value with human meaning

Use LWW when the domain genuinely wants one freshest-looking value and can tolerate overwriting concurrent alternatives:

last viewed at
presence heartbeat
cache freshness marker
temporary UI preference

Use a domain-specific CRDT when the field has structure that can merge more carefully. For example, a profile object might merge display_name, timezone, and notification_settings independently instead of treating the whole profile as one LWW blob.

The policy should be visible in the data model. A field named last_heartbeat tells maintainers that LWW is probably intentional. A field named cart_items_lww should make reviewers uncomfortable, because a cart item is not just a freshness marker.

Worked Example: Profile Preferences

Consider a profile service replicated across two regions. It stores three fields:

profile:
  login_count
  display_name
  last_seen_at

login_count is a PN-counter or grow-only counter, depending on whether the system ever subtracts. If region A sees one login and region B sees one login, the merged count should include both.

A login_count = {A:1, B:0}
B login_count = {A:0, B:1}
merged value = 2

display_name is different. If Ana writes "Ana P." in one region and "Ana Perez" in another region at the same time, the service should not assume one is meaningless. A multi-value register can preserve both until a client or application rule resolves them.

display_name siblings:
  "Ana P."     dot A:12
  "Ana Perez" dot B:7

last_seen_at is a better fit for LWW. If one region records a sighting at 10:01 and another at 10:04, keeping the later value is usually the product behavior users expect.

last_seen_at:
  keep max(timestamp, tie_breaker)

The important lesson is not that one CRDT is best. The same object can contain several CRDT policies. The profile becomes safer when each field uses a merge rule that matches its meaning.

Failure Modes

Practice

Take this replicated task object:

task:
  title
  completed
  comment_count
  last_opened_at
  assignee

Choose a merge policy for each field:

For each choice, write one sentence explaining what user-visible behavior you accept. Pay special attention to the difference between "the system shows one value" and "no update was lost."

Connections

Resources

Key Takeaways

PREVIOUS CRDTs and Coordination Avoidance: Causal Context, Version Vectors, and Dots NEXT CRDTs and Coordination Avoidance: Sets, Tombstones, and Observed-Remove Semantics