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
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.
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.
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
| Property | Type | Description |
|---|---|---|
value | number | Current counter value |
loading | boolean | True while initial state is loading |
increment | () => void | Increment by 1 |
decrement | () => void | Decrement by 1 |
add | (delta: number) => void | Add arbitrary amount |
Offline Support
PN-Counter operations work offline. Changes are persisted to local storage (IndexedDB) and synced when the connection is restored.
// 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)
// 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
| Property | Guarantee |
|---|---|
| Commutativity | Operations can be applied in any order |
| Associativity | Grouping of operations doesn’t matter |
| Idempotency | Duplicate messages are safely ignored |
| Convergence | All 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
// 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
| Method | Returns | Description |
|---|---|---|
get() | number | Get current counter value |
increment() | number | Increment by 1, returns new value |
decrement() | number | Decrement by 1, returns new value |
addAndGet(delta) | number | Add delta, returns new value |
subscribe(fn) | () => void | Subscribe to changes, returns unsubscribe |
getState() | PNCounterState | Get internal CRDT state (for sync) |
merge(state) | void | Merge 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');