Pangram verdict · v3.3
We believe that this document is fully AI-generated
AI likelihood · overall
AIArticle text · 1,745 words · 5 segments analyzed
2026-06-09 by Zixuan Chen
Two users are offline. Both add content to the same empty note. They come back online, sync finishes, and one user’s edits seem to disappear. There is no error, and the data is not actually gone from history. But can only return one Text container. The other container was created concurrently and still exists in history, but it is no longer visible in the current document state. From the application’s point of view, this looks like data loss. This is a classic problem in JSON-like CRDTs. Users have run into versions of it in the Loro, Yjs, and Automerge communities. The Appendix has short scripts that reproduce it in all three. Loro now solves this with Mergeable Containers. They make a child container’s identity come from its logical position in the , not from the ID of the operation that happened to create it. Special thanks to Alexis Williams from Synapdeck for the substantial implementation work and design discussion behind this feature. From the user’s point of view, the API change is small. Instead of creating an on-demand child container like this:
you can use a mergeable child:
As a rule of thumb, use when a child container should be identified by its logical position:
Use them for fields that should behave like one shared child container for everyone: one shared Text, one shared List, one shared Map, and so on. It should not matter which peer creates that child first. The rest of this post walks through why the problem exists and how the new encoding works. Why This Happens CRDTs are usually good at cases like “multiple users editing the same text at the same time” or “multiple users inserting into the same list concurrently.” This issue happens one layer earlier: before the peers can edit the same List, Text, or Map, they first need to agree on which child container that key refers to. Before Mergeable Containers, the recommended workaround was to initialize all required child containers as soon as the parent was created. For example, if every note always needs a text, creating that together with the note avoids the first-creation race. That workaround is useful, but it has limits. Some applications cannot know every child container ahead of time. A schema migration may add a new child container to existing documents. A calendar-like document may create child containers by date.
A dynamic index may create one child container per user-defined key. In these cases, on-demand creation is natural, and concurrent first creation is hard to avoid. The root cause is the way regular child Container IDs are represented. A normal child Container ID includes the that created it. Concurrent first creation therefore creates different Container IDs, and the Map conflict-resolution rule decides which one is visible. The issue is not that List insertion cannot merge. Once both peers are editing the same List, List edits merge normally. The issue is that the two peers created two different Lists at the same Map key. Why Root Containers Are Naturally Mergeable In Loro and Yjs, top-level Root Containers are usually accessed by name:
Here, or is already a stable identity. It does not depend on which peer created it or which operation created it. As long as multiple peers access the same root name, they naturally refer to the same logical Container.
Automerge has a different object identity model, so this root-container comparison is specifically about Loro and Yjs. The broader issue is still similar: when composite values are created concurrently at the same key, the system needs a rule for which object identity becomes visible.
Regular child Containers are different. Their identity is tied to the operation that created them, so two concurrent “first creations” become two different objects. Mergeable Containers bring the useful part of Root Container identity to selected child Containers: the child identity comes from a deterministic name, not from the creation operation. API: Explicitly Ensuring a Mergeable Child This feature does not change the existing / behavior. It adds explicit APIs for the mergeable case. In Rust, the same methods use snake case:
The word is intentional. It returns the child and, if needed, writes the marker that makes it visible at that key. Calling the same method again for the same type is idempotent. If the key already holds a regular scalar value or a regular child Container, the API returns an error instead of silently overwriting it. One subtle case is type changes. If one peer asks for a mergeable Text at while another peer asks for a mergeable Map at the same key, Loro still needs one visible value at that key. The Map’s normal conflict rule decides which type is visible.
The non-visible mergeable child’s state is still preserved under its deterministic ID, so switching back to that type can resurface it later. Core Design: Deterministic CID + Map Slot Marker Mergeable Containers have two separate layers of representation:
The child Container ID derived from the parent Container ID, key, and type. This decides whether peers address the same CRDT object. The parent Map slot. This decides whether that object is currently visible at a key, and which mergeable child type is active there.
Keeping these two layers separate makes the behavior easier to reason about. 1. CID: A Synthetic Root Container ID A Mergeable Container uses a synthetic under an internal namespace. User-created root names cannot use this prefix, so ordinary roots cannot collide with mergeable CIDs:
The payload is derived from the parent Map and the key. The Container type stays in , just like ordinary Root Containers. This lets all peers derive the same child ID without using the creation . The current encoding keeps nested mergeable Map IDs linear in the logical path length. This change was made before release to avoid recursive CID growth for deeply nested mergeable maps.
2. Map Slot: A Binary Marker Controls Visibility A deterministic CID alone is not enough because Loro has multiple Container types. If one peer calls while another peer concurrently calls , both deterministic child CIDs can exist. The parent Map still needs to decide which type is currently visible at . That decision needs to be deterministic and reversible: switching the visible type should not destroy the state of the other mergeable child. So Loro stores a small activation marker in the parent Map slot. Its meaning is:
When a new Loro client reads the slot, it uses the current to derive the deterministic mergeable CID, then presents it through the public API as a normal Container:
When the key is deleted, only the marker is removed. The mergeable child state is not immediately destroyed, because the parent slot controls visibility rather than the child’s stored history. Calling this again:
resurfaces the same deterministic Text Container. The marker is also bound to its exact parent, key, and type. That keeps it from accidentally activating a mergeable child if the same binary value is copied somewhere else.
What This Solves for Users Mergeable Containers are especially useful when eager initialization is not practical.
For example, suppose an application stores one child List per date:
Or suppose a schema migration lazily adds a new child Map to existing records:
In both cases, the child container identity no longer depends on which peer created it first. It depends on the logical position in the document structure. This makes Mergeable Containers especially useful for:
date-keyed child lists or maps schema migrations that add new child containers lazily dynamic per-user or per-entity subdocuments revision counters settings maps whose keys are discovered over time
Cost and Compatibility Mergeable Containers have some metadata cost. Their CIDs carry logical path information, so deeper paths and longer keys produce larger IDs. PR #1002 changed the encoding so nested mergeable Map IDs grow linearly instead of recursively, but very deep mergeable Map chains are still better to avoid. The compatibility story is intentionally conservative:
Existing / behavior is unchanged. Existing documents can be read normally by new versions. Mergeable Containers are introduced through new APIs, without changing existing method signatures. Older clients that do not understand this feature see the parent slot marker as an ordinary binary value, not as a fake child Container edge. They can preserve and sync the data, but they will not display the mergeable child with the new semantics. User-created root names that start with the internal prefix are rejected by Loro’s root-name validator, so they cannot collide with mergeable CIDs.
Summary Mergeable Containers are for child Containers whose identity should come from their logical position, not from whichever peer created them first. Use when:
the key is dynamic or lazily created different peers may initialize the same child while offline the child should behave like one shared Text, List, Map, Tree, or Counter deleting the key should hide the child without treating its internal history as immediately destroyed
Keep using / when:
each creation should produce a distinct child object the parent slot should point to exactly the Container created by that operation you are modeling replacement rather than shared initialization
The short version: if two peers creating the same child at the same Map key should mean “we both found the same child,” use a Mergeable Container.
References:
Loro background: issue #759 Loro implementation: PR #991, PR #1002 Related Yjs discussions: complex diagram page, losing data, nested Related Automerge discussions: #528: failing merge for text values is the closest match; #526: conflict resolution for replaced arrays and objects is useful background on object identity and conflict handling; the historical automerge-classic #4 also covers concurrently created objects under the same key.
Appendix: Runnable Reproductions The snippets below are self-contained and run directly on Node (tested on Node 24; any Node 18+ with ESM works). Install the three libraries once:
Save each block as a file and run it with . They all model the same scenario from this post: two offline peers concurrently create a child container under the same key, then sync. The Loro example also shows the fix. Loro — the bug, and the fix
Yjs — the same problem
Automerge — the same problem
Note one difference worth calling out: in Automerge the losing child is retained and can be recovered through , while Yjs overwrites the map key and drops the losing child outright. Either way, from the application’s point of view it looks like data loss — which is exactly what Mergeable Containers avoid.