Counters (GCounter, PNCounter)
Two counter CRDTs:
| Type | Value range | When |
|---|---|---|
GCounter | [0, ∞) — only goes up | Page views, total messages, completed jobs. |
PNCounter | (-∞, ∞) — both directions | Active sessions, items in cart, available stock. |
Both converge under concurrent writes by tracking per-replica contributions and summing on read.
GCounter
Section titled “GCounter”import { GCounter } from 'actor-ts';
let c = GCounter.empty();c = c.increment('node-a', 3);c = c.increment('node-b', 5);c.value(); // → 8State is a Map<ReplicaId, number> — each replica keeps its own
contribution, mergeable via per-key max.
The contract:
- Increments must be ≥ 0. Passing a negative
deltathrows. replicais each replica’s stable ID — in DistributedData, usedd.selfReplicaId().- Merge takes the per-replica max — replaying merges is idempotent.
const a = GCounter.empty().increment('a', 3);const b = GCounter.empty().increment('b', 5);
a.merge(b).value(); // → 8a.merge(b).merge(b).value(); // → 8 (idempotent)b.merge(a).value(); // → 8 (commutative)Use it for any monotonically-growing count — total events observed, total bytes uploaded, total successful jobs. Wrong shape for things that go down (cancellations, retracts).
PNCounter
Section titled “PNCounter”import { PNCounter } from 'actor-ts';
let c = PNCounter.empty();c = c.increment('node-a', 5);c = c.decrement('node-b', 2);c.value(); // → 3Internally two GCounters — one for positive contributions,
one for negative. The value is positive.value() - negative.value().
Same idempotent + commutative merge. Crucially:
- Replica IDs are tracked separately for increments and decrements. Node A incrementing then node A decrementing doesn’t subtract from the same counter — each goes to its own side.
- The value can be negative if decrements outpace increments.
const a = PNCounter.empty().increment('a', 5).decrement('a', 3);a.value(); // → 2 (5 - 3)
const b = PNCounter.empty().decrement('b', 10);a.merge(b).value(); // → -8 (5 - 13)Use for anything that goes both ways — items in cart, available stock, balance after settlements.
Using them with DistributedData
Section titled “Using them with DistributedData”import { GCounter, type DistributedData } from 'actor-ts';
const dd: DistributedData = system.extension(DistributedDataId).start(cluster);
dd.update<GCounter>( 'request-count', GCounter.empty, (c) => c.increment(dd.selfReplicaId(), 1),);
const counter = dd.get<GCounter>('request-count');console.log(counter?.value());Three things to note:
dd.selfReplicaId()is this replica’s stable ID — pass it to everyincrement/decrement.GCounter.emptyis the factory — DistributedData calls it when the key has no value yet.getis local — returns whatever the local replica has observed. For majority reads, usegetAsyncwithconsistency: 'majority'.
Picking between them
Section titled “Picking between them”| Question | Type |
|---|---|
| Does the count ever go down? | PNCounter |
| Otherwise | GCounter |
GCounter is simpler (one map, smaller wire encoding) and
safer (impossible to accidentally decrement). Use it whenever
the semantic is “only goes up.”
For anything where decrements happen, PNCounter is the right
shape — but read carefully: “items in cart” might seem like a
GCounter (“count items added”) but you’d want decrements when
items are removed; that’s PNCounter.
Where to next
Section titled “Where to next”- Distributed data overview — the bigger picture.
- Registers — single-value LWW / MV registers.
- Sets — GSet (add-only) and ORSet (with removes).
- Maps — per-key CRDT containers including GCounterMap.
- Designing data — picking the right CRDT for your problem.
The GCounter and
PNCounter API references
cover the full surface.