DocsGuidesPN-Counter

PN-Counter

The PN-Counter (Positive-Negative Counter) is a CRDT that supports both increment and decrement operations. Unlike a simple counter, it works correctly in distributed systems where multiple clients may update the counter concurrently, even while offline.

Real-time Sync

Changes sync automatically between all connected clients

Offline-First

Works offline with automatic persistence to IndexedDB

Conflict-Free

Concurrent updates merge automatically without conflicts


Basic Usage

Counter Operations
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

const client = new TopGunClient({
  serverUrl: 'ws://localhost:8080',
  storage: new IDBAdapter(),
});

// Get a counter instance
const likes = client.getPNCounter('likes:post-123');

// Increment (+1)
likes.increment();

// Decrement (-1)
likes.decrement();

// Add arbitrary amount (positive or negative)
likes.addAndGet(10);  // +10
likes.addAndGet(-5);  // -5

// Get current value
console.log('Likes:', likes.get());

Subscribing to Changes

The counter emits events when its value changes, either from local operations or remote updates.

Subscribe to Updates
const likes = client.getPNCounter('likes:post-123');

// Subscribe to value changes
const unsubscribe = likes.subscribe((value) => {
  console.log('Current likes:', value);
  updateUI(value);
});

// Later: stop listening
unsubscribe();

React Hook

The usePNCounter hook provides a reactive interface for React components.

components/LikeButton.tsx
import { usePNCounter } from '@topgunbuild/react';

function LikeButton({ postId }) {
  const { value, increment, decrement, loading } = usePNCounter(`likes:${postId}`);

  return (
    <div>
      <button onClick={decrement} disabled={loading}>-</button>
      <span>{value}</span>
      <button onClick={increment} disabled={loading}>+</button>
    </div>
  );
}

Hook Return Values

PropertyTypeDescription
valuenumberCurrent counter value
loadingbooleanTrue while initial state is loading
increment() => voidIncrement by 1
decrement() => voidDecrement by 1
add(delta: number) => voidAdd arbitrary amount

Offline Support

PN-Counter operations work offline. Changes are persisted to local storage (IndexedDB) and synced when the connection is restored.

Offline Operations
// Counter works offline - operations are queued
const likes = client.getPNCounter('likes:post-123');

// These work even without network connection
likes.increment();
likes.increment();
likes.decrement();

// Value is updated immediately in memory
console.log(likes.get()); // 1

// State is persisted to IndexedDB automatically
// When connection is restored, changes sync to server

Persistence: Counter state is automatically saved to IndexedDB using the storage adapter. When the app restarts, the counter restores its previous value before syncing with the server.


How It Works

The PN-Counter maintains two internal maps:

  • P (Positive): Tracks increments per node
  • N (Negative): Tracks decrements per node

The counter value is calculated as: sum(P) - sum(N)

Multi-Client Convergence
// Client A increments
const likesA = clientA.getPNCounter('likes:post-123');
likesA.increment(); // +1
likesA.increment(); // +1

// Client B decrements (concurrently, maybe offline)
const likesB = clientB.getPNCounter('likes:post-123');
likesB.decrement(); // -1

// After sync, both clients converge to same value
// Final value: 2 + (-1) = 1

// CRDT guarantees:
// - Commutativity: order of operations doesn't matter
// - Convergence: all replicas reach same final state
// - No conflicts: concurrent operations merge cleanly

CRDT Properties

PropertyGuarantee
CommutativityOperations can be applied in any order
AssociativityGrouping of operations doesn’t matter
IdempotencyDuplicate messages are safely ignored
ConvergenceAll replicas eventually reach the same state

Use Cases

Like/Upvote Counters

const likes = client.getPNCounter('likes:post-123');
likes.increment(); // User liked

// User can unlike
likes.decrement();

Inventory Tracking

Distributed Inventory
// Track inventory across multiple warehouses
const stock = client.getPNCounter('inventory:sku-abc');

// Warehouse A receives shipment
stock.addAndGet(100);

// Warehouse B sells items
stock.addAndGet(-5);

// Warehouse C returns
stock.addAndGet(2);

// All warehouses see consistent total
console.log('Total stock:', stock.get()); // 97

Game Scores

const score = client.getPNCounter('score:player-abc');
score.addAndGet(100);  // Points earned
score.addAndGet(-20);  // Penalty

View Counters

const views = client.getPNCounter('views:article-xyz');
views.increment(); // Page view recorded
// Note: For analytics, consider debouncing increments

API Reference

PNCounterHandle

MethodReturnsDescription
get()numberGet current counter value
increment()numberIncrement by 1, returns new value
decrement()numberDecrement by 1, returns new value
addAndGet(delta)numberAdd delta, returns new value
subscribe(fn)() => voidSubscribe to changes, returns unsubscribe
getState()PNCounterStateGet internal CRDT state (for sync)
merge(state)voidMerge remote state (automatic)

Counter Naming

Use descriptive, namespaced names for counters:

// Good: Clear entity type and ID
client.getPNCounter('likes:post-123');
client.getPNCounter('inventory:warehouse-a:sku-xyz');
client.getPNCounter('votes:poll-456:option-1');

// Avoid: Ambiguous names
client.getPNCounter('counter1');