Mergeable Containers: Fixing Concurrent Child Creation – Loro
Mergeable Containers: Fixing Concurrent Child Creation<br>Loro Protocol<br>Loro Mirror: Make UI State Collaborative by Mirroring to CRDTs<br>Loro 1.0<br>Movable tree CRDTs and Loro's implementation<br>Introduction to Loro's Rich Text CRDT<br>Loro: Reimagine State Management with CRDTs<br>crdt-richtext - Rust implementation of Peritext and Fugue
Light
BlogMergeable Containers: Fixing Concurrent Child Creation<br>Mergeable Containers: Fixing Concurrent Child Creation
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 note.get("body") 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 Map, 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:
// Peer A<br>doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "A");
// Peer B, offline<br>doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "B");
// after sync: only one List is visible at "2026-06-08"&]:_opacity-100 focus-within:_opacity-100 _flex _gap-1 _absolute _right-4 _top-2">
you can use a mergeable child:
// Peer A<br>doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "A");
// Peer B, offline<br>doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "B");
// after sync: both peers edit the same List&]:_opacity-100 focus-within:_opacity-100 _flex _gap-1 _absolute _right-4 _top-2">
As a rule of thumb, use ensureMergeable* when a child container should be identified by its logical position:
map.ensureMergeableText(key);<br>map.ensureMergeableMap(key);<br>map.ensureMergeableList(key);<br>map.ensureMergeableMovableList(key);<br>map.ensureMergeableTree(key);<br>map.ensureMergeableCounter(key);&]:_opacity-100 focus-within:_opacity-100 _flex _gap-1 _absolute _right-4 _top-2">
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 LoroMap was created. For example, if every note always needs a body text, creating that body 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 OpID 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:
doc.getMap("state");<br>doc.getText("content");&]:_opacity-100 focus-within:_opacity-100 _flex _gap-1 _absolute _right-4 _top-2">
Here, "state" or "content" 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...