Skip to content

Counters (GCounter, PNCounter)

Two counter CRDTs:

TypeValue rangeWhen
GCounter[0, ∞) — only goes upPage views, total messages, completed jobs.
PNCounter(-∞, ∞) — both directionsActive sessions, items in cart, available stock.

Both converge under concurrent writes by tracking per-replica contributions and summing on read.

import { GCounter } from 'actor-ts';
let c = GCounter.empty();
c = c.increment('node-a', 3);
c = c.increment('node-b', 5);
c.value(); // → 8

State 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 delta throws.
  • replica is each replica’s stable ID — in DistributedData, use dd.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(); // → 8
a.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).

import { PNCounter } from 'actor-ts';
let c = PNCounter.empty();
c = c.increment('node-a', 5);
c = c.decrement('node-b', 2);
c.value(); // → 3

Internally 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.

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 every increment / decrement.
  • GCounter.empty is the factory — DistributedData calls it when the key has no value yet.
  • get is local — returns whatever the local replica has observed. For majority reads, use getAsync with consistency: 'majority'.
QuestionType
Does the count ever go down?PNCounter
OtherwiseGCounter

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.

The GCounter and PNCounter API references cover the full surface.