CRDTs and Coordination Avoidance: Maps, Nested CRDTs, and Schema Evolution
LESSON
CRDTs and Coordination Avoidance: Maps, Nested CRDTs, and Schema Evolution
Core Insight
Consider a replicated task object used by a team that works from browsers, phones, and regional services:
task:
title
labels
comment_count
comments
last_opened_at
The tempting implementation is to store the whole object as one JSON blob and resolve conflicts with last-writer-wins. That converges, but it can lose unrelated work. If Ana changes the title in Madrid while Bruno adds a label in Dublin, choosing one whole-object value discards the other field even though the edits did not logically conflict.
A map CRDT changes the unit of merge. The map still looks like an object at the API, but each key is backed by its own CRDT policy. title can be a multi-value register, labels can be an observed-remove set, comment_count can be a counter, and last_opened_at can be a last-writer-wins register. The map merge aligns keys and delegates each field to the right merge rule.
The non-obvious part is that nested structure does not remove design choices. It multiplies them. Deleting a field, updating a child, renaming a key, or changing a field type all become merge semantics. The trade-off is expressive local updates versus more metadata, sharper schema discipline, and a stronger need to define what each field means under concurrency.
A Map Is Not A Blob
A map CRDT is a mapping from keys to CRDT values:
key -> CRDT value
title -> multi-value register
labels -> observed-remove set
comment_count -> PN-counter
last_opened_at -> LWW register
When two replicas merge, the outer map does not choose one object over the other. It walks the keys and merges the values stored under those keys:
Replica A:
title = "Fix login redirect"
labels = {frontend}
Replica B:
title = "Fix login redirect"
labels = {frontend, auth}
merge:
title.merge(title)
labels.merge(labels)
This is why maps are useful. They let an application keep an object-shaped model without pretending that every field has the same conflict policy.
The empty value for a field is often called bottom. Bottom means "no information yet," not "an explicit user value." For an absent counter, bottom is zero components. For an absent set, bottom is no live add dots and no relevant causal context. For an absent register, bottom is no write.
That distinction matters during schema evolution. A new field should usually start at bottom. If old replicas do not know the field, they should not create a competing default write merely by reading or rewriting the object.
Nested CRDTs
Nested CRDTs are CRDTs stored inside other CRDTs. A task can contain a map of comments, where each comment is itself a map:
task:
comments:
c1:
body -> multi-value register
reactions -> PN-counter map
resolved -> LWW register or domain-specific register
The merge path follows the structure:
task.merge
comments.merge
c1.merge
body.merge
reactions.merge
resolved.merge
Suppose A edits a comment body while B increments a reaction count:
A:
comments.c1.body = "Use the regional router"
B:
comments.c1.reactions.thumbs_up += 1
After merge, both changes survive because they touched different nested fields:
comments.c1:
body = "Use the regional router"
reactions.thumbs_up = 1
The mistake is to assume nested structure automatically gives correctness. It only gives a place to attach policies. If body is LWW, concurrent body edits can still be lost. If resolved is safety-sensitive, LWW may be too weak. If reactions uses counters, the system must still preserve replica identities and handle counter compaction safely.
Nested maps are most useful when the structure matches the domain. If comments, labels, assignees, and timestamps have different meanings, they deserve different merge rules.
Deletes Inside Maps
Deletion is where maps become subtle.
Imagine a profile map with a nested address map:
profile.address:
city -> register
country -> register
Replica A deletes address because the user removed it. At the same time, Replica B updates address.city while offline:
A:
remove profile.address
B:
profile.address.city = "Lisbon"
What should merge produce?
There is no universal answer. The data type must choose a policy:
remove-wins:
address is gone
update-wins:
address exists with city = "Lisbon"
conflict-preserving:
expose that address was removed and concurrently updated
Observed-remove maps use the same idea as observed-remove sets. A remove covers the field instances the remover has observed. A concurrent update that creates a new dot for the field may survive under add-wins or update-wins semantics. A remove-wins map records stronger removal evidence so later or concurrent child updates cannot silently revive the subtree.
This choice should be made by domain meaning, not by convenience. Removing a draft field from a collaborative note may allow a concurrent edit to restore it. Removing a user's access rule or payment instrument may need remove-wins behavior or explicit coordination.
The important rule is to define deletion at the boundary where users understand it. If "delete address" means "remove the whole address object," child updates should not accidentally make half an address visible. If "clear city" means "remove one field," the rest of the nested object should remain intact.
Worked Example: Project Preferences
Consider project preferences replicated across clients:
project:
name
labels
notification_settings
view_count
last_seen_at
A practical map design might use:
name:
multi-value register
labels:
add-wins OR-set
notification_settings:
nested map:
email_enabled -> LWW register
digest_hour -> LWW register
muted_topics -> OR-set
view_count:
grow-only counter or PN-counter
last_seen_at:
LWW register
Now suppose three replicas update different parts while disconnected:
A changes:
name = "Search Platform"
B changes:
labels += "production"
notification_settings.muted_topics += "deployments"
C changes:
view_count += 1
last_seen_at = 10:05
The merge keeps all independent changes:
project:
name = "Search Platform"
labels = {production}
notification_settings:
muted_topics = {deployments}
view_count = previous + 1
last_seen_at = 10:05
That is the benefit of a map CRDT: the object converges by composing smaller merges. It avoids making last_seen_at and name obey the same rule.
But the map also makes review harder. If someone later changes notification_settings from a nested map into one LWW JSON blob, a concurrent edit to digest_hour can overwrite a concurrent edit to muted_topics. The schema change has changed the merge contract, not only the storage format.
Schema Evolution Is Merge Evolution
Schema evolution is the discipline of changing stored shape while old and new replicas may coexist. In a coordinated database migration, you can often stop writers or force all code through one version. In coordination-avoiding replication, old clients may keep writing old fields while new clients write new fields.
Adding a field is usually the easiest case:
old schema:
project.name
project.labels
new schema:
project.name
project.labels
project.color
If color starts at bottom, old replicas simply ignore it. New replicas can merge it when present. The risk appears when a default is treated as a write. If every old client rewrites color = "blue" because it does not know better, it may overwrite a real user choice made by a new client.
Renaming a field is harder:
old:
display_name
new:
name
During rollout, some replicas may write display_name while others write name. If those fields are independent, the system now has two names. A safe rename usually needs a bridge period:
read from name if present, otherwise display_name
write both fields or write a migration record
merge old and new values with an explicit rule
retire the old field only after old writers are gone
Changing a field type is the hardest case. A counter, a set, and an LWW register do not preserve the same information. Moving from labels as an LWW list to labels as an OR-set cannot be done by merely changing the decoder. The migration must explain how old writes and new writes combine while both exist.
Good schema evolution asks three questions:
What is bottom for the new field?
How do old and new writes merge during rollout?
When is it safe to drop old metadata, tombstones, or compatibility code?
If the answer requires "all replicas have upgraded before any concurrent write," the design is no longer coordination-avoiding for that migration.
Operational Failure Modes
- Treating a map as an LWW JSON blob: Independent field updates can overwrite each other even though the application object converges.
- Using one field policy everywhere: Counters, sets, registers, and timestamps represent different kinds of facts.
- Letting child updates revive deleted parents: A nested update after a parent delete must follow an explicit add-wins, update-wins, remove-wins, or conflict-preserving policy.
- Confusing absence with a default write: Old replicas that do not know a field should not manufacture writes that compete with real values.
- Renaming fields without a bridge: Old and new clients can create parallel facts that never reconcile.
- Changing CRDT type without a compatibility rule: A new merge policy can discard information that the old policy preserved.
- Dropping schema metadata too early: Tombstones, old field names, or migration markers may still be needed to merge delayed state safely.
Practice
Design a CRDT map for this replicated workspace object:
workspace:
title
members
pinned_documents
billing_plan
last_activity_at
For each field, choose a merge policy and explain one accepted trade-off. Then choose one schema change, such as renaming title to name or replacing pinned_documents with a richer nested map. Write the compatibility rule that lets old and new clients merge during rollout.
If a field affects authorization, billing, or legal responsibility, do not default to the most available CRDT. Mark the invariant and decide whether remove-wins semantics or coordination is required.
Connections
007.mdintroduced field-level choices between counters, registers, and LWW policies.008.mdexplained the observed-remove metadata that maps reuse for field deletion and nested key removal.010.mdasks when invariants and monotonicity allow coordination avoidance at the program level, beyond individual data types.
Resources
- [PAPER] A comprehensive study of Convergent and Commutative Replicated Data Types
- Focus: Compare the formal definitions for maps, registers, counters, and sets as composable CRDTs.
- [PAPER] A Conflict-Free Replicated JSON Datatype
- Focus: Study how JSON-like nested structures can preserve concurrent updates without collapsing the object into one LWW blob.
- [DOC] Riak Data Types: Maps
- Focus: Look at how a production database exposes nested CRDT maps and field-level data types.
- [BOOK] Designing Data-Intensive Applications
- Focus: Read the replication and data-model sections with attention to schema evolution under concurrent writes.
Key Takeaways
- A map CRDT merges an object by key, then delegates each field to its own CRDT merge rule.
- Nested CRDTs preserve independent updates only when each nested field has a policy that matches its domain meaning.
- Deletes inside maps need explicit semantics for parent removal versus concurrent child updates.
- Schema evolution changes merge behavior; safe migrations define bottom values, bridge old and new writes, and keep compatibility metadata until delayed state can no longer arrive.