WithDataCenterReplication, the same CRDTs can also replicate across datacenters (leader batch flush over remoting), while each DC keeps its own cluster and TopicActor fan-out.
Architecture
Each node in the cluster runs its own Replicator system actor (GoAktReplicator). There is no central coordinator — each Replicator owns its node’s local CRDT store and communicates with peer Replicators via GoAkt’s existing TopicActor pub/sub system.

Data Flow
Step 1 — Subscription at startup. When a Replicator starts (inPostStart), it subscribes to the well-known topic goakt.crdt.deltas via the TopicActor. This is a one-time operation — there is no per-key subscription. Every delta for every key flows through this single topic.
Step 2 — Local update. A user actor sends an Update message to its local Replicator. The Replicator applies the mutation to its local CRDT store, extracts the delta, encodes it as a protobuf CRDTDelta message (which includes the key ID, data type, origin node, and serialized delta state), and publishes it to the shared topic via the TopicActor.
Step 3 — Dissemination. The TopicActor delivers the protobuf delta to all subscribers of goakt.crdt.deltas — which is the Replicator on every other node. The TopicActor handles serialization (protobuf), remote TCP delivery, and deduplication. This is not a Replicator concern.
Step 4 — Remote merge. Each peer Replicator receives the protobuf delta, decodes it back to the CRDT type, ignores it if the origin is itself, and merges it into its local store using the type’s merge function. If the merge changes the value, it notifies any local user actors watching that key.
Both Replicators are equal participants — they both subscribe to the same topic and both can publish to it. There is no publisher/subscriber distinction. When Node B updates the same key, the flow is identical with roles reversed.
Anti-entropy runs periodically as a safety net. Each Replicator exchanges version digests with a random peer, and any divergent keys are repaired. This ensures convergence even if some deltas were lost during a network partition.
CRDT types
All types implement theReplicatedData interface and are immutable values — every mutation returns a new value plus a delta. Values stored in generic CRDT types (LWWRegister, ORSet, MVRegister, ORMap) are typed as any and serialized over the wire using a composite Proto+CBOR serializer. Go primitives (string, int, bool, etc.) work out of the box; custom struct types require CBOR registration via remote.WithSerializers.
Keys
CRDT keys are serializable and carry the CRDT data type for wire-level validation.Enabling CRDTs
CRDT replication is enabled throughClusterConfig.WithCRDT(...). When not enabled, no Replicator is spawned — zero overhead.
Configuration options
Multi-datacenter replication
When you run one GoAkt cluster per datacenter, CRDT deltas still replicate inside each cluster via TopicActor. To also replicate across DCs, enable multi-DC on the cluster and cross-DC CRDT on the CRDT config. RequirementsClusterConfig.WithDataCenter(...)with a validateddatacenter.Config(control plane + localDataCentermetadata).ClusterConfig.WithCRDT(...)includingcrdt.WithDataCenterReplication()(and optional anti-entropy / interval / send-timeout options).
- Every node runs a Replicator; only the cluster leader periodically sends a protobuf
CRDTDeltaBatch(buffered deltas and tombstones, origin DC, send timestamp) to remote DCs using the DC controller’sActiveRecords()and remoting. - Remote Replicators apply the batch locally; those updates then spread within that DC through the usual
goakt.crdt.deltastopic. - Optional cross-DC anti-entropy sends a digest to a random remote DC on a slower schedule, similar in spirit to intra-DC anti-entropy.
crdt.DCMajority and crdt.DCAll exist for future coordinated read/write across DCs; today, use Majority / All for intra-cluster coordination only.
Using the Replicator
The Replicator PID is accessed viaActorSystem.Replicator(). It returns nil if CRDTs are not enabled. All interaction uses standard Tell and Ask from within an actor’s Receive handler.
Writing data
Send anUpdate to the Replicator. The Modify function receives the current value as ReplicatedData — use a type assertion to access the concrete type.
Reading data
Send aGet to the Replicator. The response’s Data field is ReplicatedData — use a type assertion to access the concrete type.
Subscribing to changes
Actors can watch keys and receiveChanged notifications whenever the value changes — from local updates or peer deltas.
Deleting data
Consistency model
Default: local-first
Every operation is local-first by default:- Writes apply the mutation locally and publish the delta asynchronously. The Replicator returns immediately.
- Reads return the local value immediately.
Optional coordination
When stronger guarantees are needed, setWriteTo or ReadFrom on the message:
When
ReadFrom: Majority and WriteTo: Majority are used together, the system provides strong eventual consistency with read-your-writes guarantees.
crdt.DCMajority and crdt.DCAll are reserved for future cross-DC coordinated operations; today they do not add remote DC fan-out beyond normal local apply + TopicActor publish.
Durable snapshots
By default, the CRDT store is purely in-memory. If the Replicator crashes, state is rebuilt from peers via anti-entropy. For faster recovery, enable durable snapshots to persist the store to BoltDB periodically.- The Replicator saves the full store to BoltDB at the configured interval.
- On startup (or supervisor restart), the Replicator restores from the latest snapshot before participating in anti-entropy.
- A final snapshot is persisted during graceful shutdown. The snapshot file is retained on disk so the Replicator can restore quickly on the next startup.
Observability
WhenWithMetrics() is enabled on the actor system, the Replicator registers OpenTelemetry instruments:
All instruments carry an
actor.system attribute with the actor system name.
Full example
A complete working example: aSessionTracker actor that uses an ORSet to maintain a cluster-wide set of active sessions, and a SessionReporter that watches for changes.
Limitations
CRDTs are a powerful tool for distributed state, but they are not a silver bullet. Be aware of the following limitations before choosing this approach.Eventual consistency only
CRDTs guarantee convergence, not immediate consistency. If your domain requires strong consistency (e.g., financial transactions, inventory with strict accuracy), CRDTs are not the right fit. The optionalWriteTo/ReadFrom coordination levels (Majority, All) improve consistency guarantees but still do not provide linearizability.
Not intended for Big Data
The CRDT subsystem is designed for moderate working sets, not billions of entries:- All data is held in memory. The in-memory store (
map[string]crdt.ReplicatedData) is bounded only by the Go runtime’s memory limits. There is no disk-backed storage for the working set — snapshots are a recovery optimization, not a storage tier. - New node convergence cost. When a new node joins the cluster, it starts with an empty store and catches up via anti-entropy (periodic digest exchange with random peers, default every 30 seconds). All existing nodes collaborate, but transferring a large number of entries takes time proportional to the store size.
- No built-in entry count limit. There is no enforced ceiling, but as a practical guideline, keep the number of top-level keys reasonable for your cluster size and network bandwidth. Tens of thousands of keys is fine; millions will strain anti-entropy and increase convergence time for new nodes.
Full-state transfer for large values
When a data entry is changed, the Replicator publishes a delta (incremental change) to peers. However, the full state of the entry is replicated in several situations:- The serialized delta exceeds
MaxDeltaSize(default 64KB) — the Replicator falls back to full-state transfer. - During anti-entropy, when a peer is detected to be behind on a key.
- When new nodes join and need to catch up from scratch.
CRDT garbage accumulation
Some CRDT types accumulate metadata that grows over the lifetime of the system:- GCounter and PNCounter keep a counter slot per node ID. If many nodes are added and removed over time, the counter maps retain entries for every node that ever incremented them. There is currently no automatic pruning of departed node slots from counters — these entries persist indefinitely. For long-running clusters with high node churn, this can become a source of unbounded (though individually small) memory growth.
- ORSet and ORMap track causal dots (one per unique add operation). Periodic compaction (
Compactableinterface) reduces redundant dots by keeping only the highest counter per node, but the number of distinct node entries still grows with cluster membership history.
Single-topic dissemination
All deltas for all keys flow through one TopicActor topic (goakt.crdt.deltas). This simplifies the protocol but means a high update rate on many keys produces proportional TopicActor throughput. For most workloads this is not a bottleneck.
Cross-datacenter replication caveats
Cross-DC replication depends on the DC control plane (for active DC records), cluster leadership (only the leader sends batches), and WAN latency. If the control plane is unavailable or the DC cache is stale, cross-DC flush may pause; intra-DC replication continues. TuneWithDataCenterSendTimeout, replication interval, and datacenter FailOnStaleCache for your SLOs.
Tombstone window
Deleted keys are protected by a tombstone for the configured TTL (default 24 hours). If a node is partitioned for longer than the TTL and still holds the key, it may resurrect the key when it rejoins. SetWithTombstoneTTL appropriately for your partition tolerance requirements.
Value serialization
CRDT values are serialized using a composite Proto+CBOR serializer. Go primitives (string, int, bool, float64, etc.) and proto.Message types work out of the box. Custom struct types must be registered via remote.WithSerializers(new(MyType), remote.NewCBORSerializer()).
No partial updates
TheModify function in Update receives the full current value and must return the full updated value. There is no patch or field-level delta mechanism.