Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Cassandra Distributed Counters

Counting is among the most deceptively simple operations in computing. On a single machine, incrementing a counter is trivial: read the current value, add one, write the result. This read-modify-write cycle executes atomically, and the counter maintains perfect accuracy regardless of how many clients perform concurrent increments.

Distributed systems shatter this simplicity. When data is replicated across multiple nodes, each capable of accepting writes independently—the read-modify-write pattern breaks down fundamentally. Two clients incrementing “simultaneously” may each read the same value, compute the same result, and write it to different replicas. When those replicas synchronize, one increment vanishes. The system has lost information that was successfully acknowledged to a client.

This problem has no trivial solution. Strong consistency mechanisms like distributed locks or consensus protocols can serialize all counter operations, but at the cost of availability and latency that make them impractical for high-throughput counting scenarios. The challenge is achieving accurate counting while preserving the availability and partition tolerance that define distributed databases.

Cassandra addresses this through a specialized counter implementation based on Conflict-free Replicated Data Types (CRDTs). Rather than maintaining a single value that all nodes modify, each node maintains its own independent contribution. These contributions can be merged deterministically without coordination, ensuring that all acknowledged increments are preserved even when replicas diverge and later reconcile.


Counters were one of the most requested features in Cassandra’s early years. Use cases abounded: page view tracking, vote tallying, rate limiting, real-time analytics, inventory management. All required incrementing values at high throughput across a distributed cluster.

Before native counter support, developers resorted to workarounds: external counting services, application-level aggregation, or accepting inaccuracy. None were satisfactory. The community pressure for native counter support was substantial.

Cassandra 0.8 introduced counters as a new column type. The implementation seemed straightforward: each replica maintained its own count, and these were merged during reads by summing the per-replica values.

The initial design suffered from fundamental flaws:

ProblemMechanismImpact
OvercountingNetwork timeouts triggered client retries; the same increment applied multiple timesCounter values inflated unpredictably
UndercountingReplica failures during write could lose increments that were partially appliedCounter values lower than expected
Read inconsistencyDifferent read paths could compute different sums from diverged replicasSame counter returned different values
Repair corruptionAnti-entropy repair could not correctly reconcile diverged counter statesRepair sometimes made inconsistency worse

These issues were not edge cases—they manifested regularly in production workloads. Counter accuracy could drift by 10% or more under normal operating conditions. For use cases requiring precise counting, this was unacceptable.

Version 1.2–2.0: Incremental Fixes (2012–2013)

Section titled “Version 1.2–2.0: Incremental Fixes (2012–2013)”

Subsequent releases attempted to patch the counter implementation:

  • Improved write path to reduce partial application failures
  • Better replica coordination during counter reads
  • Reduced (but did not eliminate) overcounting from retries

The fundamental architecture remained unchanged. Each patch addressed symptoms while the underlying data model continued to cause problems. Counter reliability improved but remained inferior to regular column operations.

Cassandra 2.1 (CASSANDRA-6504) replaced the counter implementation entirely. The new design drew from CRDT research and addressed the root causes of previous issues:

Architectural Changes:

AspectPre-2.1Post-2.1
Data modelSingle merged value per replicaPer-node shards with individual timestamps
Write handlingIncrement applied directlyIncrement applied to coordinator’s shard only
Conflict resolutionSum of replica valuesMax timestamp per shard, then sum
Retry safetyRetries cause overcountingTimestamps make retries idempotent
Repair behaviorCould corrupt valuesCorrectly merges shard states

The key insight was separating each node’s contribution into an independently-managed shard. When replicas diverge and reconcile, the merge operation takes the maximum value for each shard (based on timestamp), then sums across shards. This guarantees that no increment is lost and no increment is counted twice.

Version 3.0+: Maturity and Limitations (2015–Present)

Section titled “Version 3.0+: Maturity and Limitations (2015–Present)”

The 2.1 redesign stabilized counter behavior. Subsequent releases focused on:

  • Performance optimizations for counter reads and writes
  • Better integration with repair and streaming
  • Explicit exclusion from features incompatible with counter semantics (materialized views, certain secondary index types)

Modern Cassandra counters are reliable for their intended use cases, though they retain inherent limitations stemming from their distributed nature.


Consider how a naive distributed counter might work:

Initial state: counter = 100 on all replicas
Client A (via Replica 1): Client B (via Replica 2):
1. Read counter: 100 1. Read counter: 100
2. Compute: 100 + 1 = 101 2. Compute: 100 + 1 = 101
3. Write counter: 101 3. Write counter: 101
After synchronization (last-write-wins):
All replicas: counter = 101
Expected: 102
Actual: 101
Lost: 1 increment

The problem is fundamental: Cassandra uses timestamps for conflict resolution. When two replicas have different values for the same column, the value with the higher timestamp wins. Counter increments are not additive under this model—they overwrite each other.

Potential solutions and why they fail:

ApproachProblem
Distributed locksSerializes all increments; destroys throughput and availability
Consensus per incrementLatency of Paxos round for every increment; impractical at scale
Increment-only logUnbounded growth; read requires scanning entire history
Last-write-winsLoses concurrent increments (as shown above)

Consider a conference with three entrance doors. The task: count total attendance accurately.

Naive approach (mirrors last-write-wins):

Each door volunteer maintains a “total attendance” counter. Periodically, they synchronize by sharing their counts.

Door A volunteer: "I've counted 50 people total"
Door B volunteer: "I've counted 30 people total"
Door C volunteer: "I've counted 20 people total"
Synchronization question: What is the total attendance?

If synchronization takes the maximum (like last-write-wins), the answer is 50—obviously wrong. If it sums the values, the answer is 100—but this only works if each volunteer’s count represents only their door’s arrivals. If volunteers were attempting to track total attendance (by adding what they heard from others), summing produces overcounting.

Correct approach (mirrors Cassandra’s solution):

Each volunteer tracks only their own door’s arrivals, clearly labeled:

Door A's arrivals: 50
Door B's arrivals: 30
Door C's arrivals: 20
Total = A + B + C = 100 ✓

When synchronizing, volunteers share their individual door counts. The merge operation is simple: for each door, take the highest reported count (in case updates crossed in transit), then sum across all doors.

Volunteer 1 knows: { A: 50, B: 28, C: 20 }
Volunteer 2 knows: { A: 48, B: 30, C: 20 }
Merged state: { A: max(50,48)=50, B: max(28,30)=30, C: max(20,20)=20 }
Total = 100 ✓

This works because:

  1. Each door’s count is independent and monotonically increasing
  2. Taking the maximum for each door is always safe (higher value is more recent)
  3. Summing independent counts produces the correct total

This is precisely how Cassandra counters work: each node maintains its own “shard” of the counter, and the global value is computed by merging shards and summing.


CRDTs: Conflict-Free Replicated Data Types

Section titled “CRDTs: Conflict-Free Replicated Data Types”

Cassandra’s counter implementation is grounded in research on Conflict-free Replicated Data Types (CRDTs), formalized by Shapiro et al. (Shapiro, M. et al., 2011, “A Comprehensive Study of Convergent and Commutative Replicated Data Types”).

CRDTs are data structures designed for replicated systems where:

  • Replicas can be updated independently without coordination
  • Updates propagate asynchronously between replicas
  • All replicas converge to the same state once all updates are delivered

The key property is that the merge operation is:

PropertyDefinitionCounter Application
Commutativemerge(A, B) = merge(B, A)Order of receiving updates doesn’t matter
Associativemerge(merge(A, B), C) = merge(A, merge(B, C))Grouping of merges doesn’t matter
Idempotentmerge(A, A) = AReceiving same update twice has no effect

These properties guarantee convergence regardless of network delays, reordering, or duplication.

The simplest CRDT counter is the G-Counter (grow-only), which supports only increments:

G-Counter state: map from node_id to count
{ node_A: 50, node_B: 30, node_C: 20 }
Value = sum of all counts = 100
Increment (on node B):
{ node_A: 50, node_B: 31, node_C: 20 }
Merge (take max per node):
State 1: { node_A: 50, node_B: 30 }
State 2: { node_A: 48, node_B: 35, node_C: 20 }
Merged: { node_A: 50, node_B: 35, node_C: 20 }

The merge operation (per-node maximum) preserves all increments because each node’s count only increases. If node B’s count is 35 in one state and 30 in another, 35 must include all increments represented by 30, plus additional ones.

To support decrements, the PN-Counter maintains separate positive and negative counts:

PN-Counter state: map from node_id to (positive, negative)
{
node_A: { P: 60, N: 10 },
node_B: { P: 30, N: 0 },
node_C: { P: 25, N: 5 }
}
Value = sum(P) - sum(N) = (60+30+25) - (10+0+5) = 115 - 15 = 100
Increment by 5 (on node B): node_B.P += 5
Decrement by 3 (on node B): node_B.N += 3
Merge: take max for each (node, P/N) pair

Cassandra’s counter implementation is a variant of the PN-Counter, optimized for its storage engine and consistency model.

In Cassandra’s implementation, each counter cell contains multiple “shards”:

Counter cell for (partition_key, column_name):
Shard 1: { host_id: A, count: 150, clock: 1705312800000 }
Shard 2: { host_id: B, count: 75, clock: 1705312850000 }
Shard 3: { host_id: C, count: -25, clock: 1705312700000 }
Counter value = 150 + 75 + (-25) = 200

Each shard represents one node’s cumulative contribution to the counter. The clock (timestamp) enables proper conflict resolution when the same shard appears with different values.

Merge semantics:

Shard from Replica 1: { host_id: B, count: 75, clock: 1000 }
Shard from Replica 2: { host_id: B, count: 80, clock: 1200 }
Merged shard: { host_id: B, count: 80, clock: 1200 }
The higher clock indicates a more recent state that includes all
previous increments plus additional ones.

Counter updates follow a different path than regular writes:

UPDATE page_views SET count = count + 1 WHERE page_id = 'home';
Counter Write PathCounter Write Path1. REQUEST ARRIVES AT COORDINATOR Client sends increment requestCoordinator identified (may be any node)2. LOCAL READ (Critical Step) Coordinator reads current counter value from LOCAL replicaThis retrieves coordinator's current shard valueExample: coordinator's shard currently at 1503. COMPUTE NEW SHARD VALUE new_value = current_shard + increment = 150 + 1 = 151new_clock = current_timestamp4. REPLICATE TO ALL REPLICAS Coordinator sends: { host_id: self, count: 151, clock: now }Sent to ALL replicas (not just CL requirement)Each replica merges shard into its counter cell5. WAIT FOR ACKNOWLEDGMENTS Wait for CL replicas to acknowledgeReturn success to client

Key implications:

CharacteristicExplanation
Read-before-writeEvery counter update requires a local read, adding latency
Replica-local shardsEach replica uses its own CounterId for its shard
Logical clockShards use logical clocks (not requiring global timestamp accuracy)
Higher overheadCounter writes have additional overhead compared to regular writes

Reading a counter aggregates shards from multiple replicas:

SELECT count FROM page_views WHERE page_id = 'home';
Counter Read PathCounter Read Path1. CONTACT REPLICAS Coordinator contacts CL replicasEach replica returns its complete counter cell (all shards)2. MERGE SHARDS Replica 1: { A: 150@t1, B: 75@t2 }Replica 2: { A: 150@t1, B: 80@t3, C: 25@t4 } Merged: { A: 150, B: 80 (higher clock), C: 25 }3. COMPUTE VALUE Sum all shards: 150 + 80 + 25 = 255Return to client4. READ REPAIR (if needed) If replicas had different shard sets, trigger repairEnsures replicas converge

Counter operations interact with consistency levels differently than regular operations:

OperationCL Effect
Write (increment/decrement)Determines how many replicas must acknowledge the shard update
ReadDetermines how many replicas contribute shards to the merge

Write consistency considerations:

RF = 3, Write with CL = ONE:
1. Coordinator reads local shard
2. Coordinator computes new shard value
3. Coordinator sends update to all 3 replicas
4. Waits for 1 acknowledgment
5. Returns success
Risk: If the acknowledging replica fails before others receive
the update, the increment exists on only one replica until
repair or read-triggered merge.
Recommendation: Use QUORUM for counter writes to ensure
the update reaches a majority of replicas.

Read consistency and accuracy:

Counter has diverged shards across replicas:
Replica 1: { A: 100, B: 50 } Total: 150
Replica 2: { A: 100, B: 50, C: 30 } Total: 180
Replica 3: { A: 100, B: 55, C: 30 } Total: 185
Read with CL = ONE (contacts Replica 1):
Returns 150 (missing C's shard entirely)
Read with CL = QUORUM (contacts Replica 1 and 2):
Merged: { A: 100, B: 50, C: 30 }
Returns 180 (missing B's latest update)
Read with CL = ALL (contacts all):
Merged: { A: 100, B: 55, C: 30 }
Returns 185 (complete, accurate)

Higher read consistency levels produce more accurate results when replicas have diverged.


These limitations arise from the distributed counter design itself:

LimitationReason
No absolute value assignmentCannot set counter to specific value; only increments/decrements allowed
No conditional updatesCannot do “increment if value < 100”; would require distributed coordination
Accuracy requires quorumLower consistency levels may miss recent updates
Shard accumulationCounters updated by many nodes accumulate shards, increasing storage

Counter tables have special requirements:

-- Counter table structure
CREATE TABLE metrics (
metric_id text,
bucket timestamp,
count counter,
PRIMARY KEY (metric_id, bucket)
);
-- VALID: Multiple counter columns
CREATE TABLE multi_counter (
id text PRIMARY KEY,
reads counter,
writes counter,
errors counter
);
-- INVALID: Mixing counter and non-counter columns
CREATE TABLE invalid_mixed (
id text PRIMARY KEY,
count counter,
name text -- ERROR: cannot mix types
);
-- INVALID: Counter as primary key
CREATE TABLE invalid_pk (
count counter PRIMARY KEY -- ERROR
);

Counters are explicitly excluded from several Cassandra features:

FeatureStatusReason
Secondary indexesNot supportedCounter values are computed, not stored directly
Materialized viewsNot supportedView maintenance incompatible with counter semantics
TTLNot supportedExpiring shards would corrupt counter value
Lightweight transactions (IF)Not supportedConditional updates require different consistency model
USING TIMESTAMPNot supportedCounters manage their own timestamps internally
ConsiderationDetails
Write latencyHigher than regular writes due to read-before-write
Storage overheadEach updating replica adds a shard
Repair behaviorCounter repair uses different mechanics; ensure counters are included in repair schedule
Backup/restoreCounter state must be consistent across all replicas after restore

-- Increment by 1
UPDATE page_views SET views = views + 1
WHERE page_id = 'home';
-- Increment by arbitrary amount
UPDATE page_views SET views = views + 100
WHERE page_id = 'home';
-- Decrement
UPDATE page_views SET views = views - 1
WHERE page_id = 'home';
-- Decrement by arbitrary amount
UPDATE page_views SET views = views - 50
WHERE page_id = 'home';
-- Update multiple counters
UPDATE metrics
SET reads = reads + 1, writes = writes + 5
WHERE metric_id = 'api';
-- Read counter value
SELECT views FROM page_views WHERE page_id = 'home';
-- Counter batch (reduces round trips, but NOT atomic)
BEGIN COUNTER BATCH
UPDATE page_views SET views = views + 1
WHERE page_id = 'home';
UPDATE page_views SET views = views + 1
WHERE page_id = 'about';
UPDATE page_views SET views = views + 1
WHERE page_id = 'contact';
APPLY BATCH;

Warning: Counter batches are NOT atomic. Individual updates may succeed or fail independently. Use batches for performance (reducing round trips), not for transactional guarantees.

-- Delete entire row (resets all counters in that row)
DELETE FROM page_views WHERE page_id = 'home';
-- Cannot delete individual counter column without deleting row
-- Cannot reset counter to zero (must delete and re-increment)

PracticeRationale
Dedicated counter tablesCannot mix counter and non-counter columns
Time-bucketed partitionsPrevents unbounded partition growth; enables cleanup
Meaningful partition keysCounter updates route through partition coordinator

Example: Time-bucketed counters

CREATE TABLE hourly_page_views (
page_id text,
hour timestamp,
views counter,
PRIMARY KEY (page_id, hour)
) WITH CLUSTERING ORDER BY (hour DESC);
-- Current hour
UPDATE hourly_page_views SET views = views + 1
WHERE page_id = 'home'
AND hour = '2024-01-15 14:00:00';
-- Query recent hours
SELECT hour, views FROM hourly_page_views
WHERE page_id = 'home'
LIMIT 24;
-- Delete old data (no TTL available)
DELETE FROM hourly_page_views
WHERE page_id = 'home' AND hour < '2024-01-01';
Use CaseWrite CLRead CLTrade-off
Accurate countingQUORUMQUORUMHigher latency, accurate results
Approximate countingONEONELower latency, may miss recent updates
Critical countsALLALLHighest latency, strongest guarantees
ScenarioAlternative Approach
Need exact accuracy with low latencyExternal counting service (Redis, etc.)
Extreme write throughput (>100K/sec)Application-side aggregation, periodic flush
Need TTL on countsTime-bucketed regular columns with TTL
Need conditional updatesRegular columns with lightweight transactions
Need secondary indexes on count valuesMaintain separate summary table

# Write latency (includes read-before-write)
org.apache.cassandra.metrics:type=Table,keyspace=ks,scope=tbl,name=WriteLatency
# Coordinator read latency (the read portion of counter update)
org.apache.cassandra.metrics:type=Table,keyspace=ks,scope=tbl,name=CoordinatorReadLatency
# Local read latency
org.apache.cassandra.metrics:type=Table,keyspace=ks,scope=tbl,name=ReadLatency
SymptomLikely CauseResolution
Counter value lower than expectedUpdates lost to failed replicasUse higher write CL; run repair
Inconsistent readsReplicas have diverged shardsUse higher read CL; run repair
High counter write latencyNormal read-before-write overheadExpected; optimize partition design
Counter stops updatingCoordinator failuresCheck cluster health; verify connectivity