---
topic: system-design
author: Crashtech Editorial
published: Nov 14, 2026 · read: 5 min
---

Counting at Global Scale: Best-Effort vs Eventually Consistent Counters

A counter looks trivial until it spans regions and retries. Five designs compared, and a hybrid: log increments, roll up in immutable windows, cache reads.

– –

Counters are where “simple” systems go to die. A view counter, an experiment tally, a usage meter for billing: all are +1, and all fail differently. The first design decision is not how to count, it is how wrong you are allowed to be.

Two tiers of correctness

Best-effortEventually consistent
PrioritySpeed and costAccuracy and durability
Typical useA/B test counts, rate approximations, transient metricsBilling, compliance, usage reports
Loses or double-counts?PossibleNot in the final value
RetentionShort, with a TTLLong
Stale readsAcceptableAcceptable, but converging to exact

Both tiers share the platform requirements: high availability, high throughput and horizontal scale across regions, including spikes on launch day.

A small, safe API

POST add_and_get_count
{
  "namespace": "user_metrics",
  "counter_name": "views",
  "delta": 5,
  "idempotency_token": { "token": "evt-8f3a", "generation_time": "2026-11-14T10:00:00Z" }
}
  • AddAndGetCount: apply a positive or negative delta, return the new value.
  • GetCount: read, possibly slightly stale for speed.
  • ClearCount: reset, also idempotent.

The idempotency token is the important field. Without it, any network retry silently inflates the count.

Five ways to build one, and where each breaks

1. Cache counter best-effort
An atomic increment in a distributed cache with a TTL. Millisecond latency, cheap, shared infrastructure. No cross-region replication, no idempotency, cannot guarantee consistency across nodes. Fine for experiments.
2. One row per counter simple, contended
Every increment updates a single row in a replicated store. Easy to understand, but hot counters contend on the row, retries double-count, and cross-region conflicts need care.
3. Per-instance aggregation batched writes
Each server keeps counts in memory and flushes periodically (with jitter to avoid synchronised bursts). Cuts write load, but a crash before a flush loses counts, and resets are hard to coordinate across instances.
4. Durable queue log, then aggregate
Increments go to a partitioned log keyed by counter, consumers aggregate in batches. Reliable and idempotency is easier. Costs delay when consumers lag and complexity when rebalancing partitions.

5. Event log of increments. Store every increment as an event with event_time and event_id. The pair doubles as the idempotency key, and totals can always be recomputed. The costs: storage grows without bound, reading means scanning all events for a counter, and wide partitions in wide-column stores slow queries.

The hybrid: log, roll up, cache

Each option fixes one thing and breaks another, so the production design combines three of them.

  1. Log every increment in a time-series store

    Record event_time, a unique event_id and the counter key. Group events into time buckets (a minute or an hour) so no partition grows unboundedly. Unique ids make retries harmless.

  2. Roll up in the background over immutable windows

    A rollup process sums events in windows that can no longer change, then writes the total and a last rollup timestamp to durable storage. The next rollup starts from that checkpoint, so cost scales with new events, not total history.

  3. Serve reads from a cache

    The cache holds the last rolled-up count and its timestamp. A read returns instantly, and if the value is stale it triggers a background rollup for next time.

Why immutable windows are the key trick

If you aggregate a window that can still receive late events, your stored total is silently wrong. Only roll up what is closed. The count lags by a small, bounded delay, and in exchange it never drifts.

Advertisement

Scaling the rollup pipeline

Rollups are triggered by tiny rollup events that say “this counter needs aggregating”, with no data in them. Design points:

  • In-memory queues per rollup server, giving parallelism without a durable queue to run. A fast non-cryptographic hash (such as xxHash) routes the same counter to the same queue so work is not duplicated.
  • Deduplicate within a queue. Many triggers for one counter collapse into a set, so it rolls up once per window.
  • Batch dynamically with back-pressure. Process counters in batches, and slow intake when the store or workers are saturated.
  • Accept the risk, bound it. If a rollup server crashes, queued triggers vanish, but frequently used counters get re-triggered by the next read or write, and the event log still holds the truth. Overlap during deployments is safe, because windows are immutable.
  • Scale by adding queues and redeploying servers that drain gracefully.

Low-cardinality counters converge almost immediately. Millions of high-cardinality counters converge as batches work through, so tune window size and batch settings against how fresh the data must be.

Choosing quickly

NeedUse
Rough experiment tallies, short lifeCache counter with TTL
Billing, compliance, durable totalsHybrid event log with rollups
Unique counts (not totals)A probabilistic sketch such as HyperLogLog
Counters merged across regions with no coordinationA CRDT counter
One server’s metricsIn-process counters flushed to a metrics system

Do

Decide the tier per metric, always include an idempotency key on durable counts, keep raw events long enough to recompute, and test crash scenarios by killing a rollup server mid-window.

Don't

Use a cache counter for money, or one hot database row for a counter that every request touches. Both work at demo scale and fail loudly at real scale.

Takeaway

Counting at scale is a choice about error, not arithmetic. Best-effort counters trade accuracy for speed and cost, and durable ones keep every increment, deduplicate by id, roll up closed windows into checkpoints and serve cached results. Use the cheapest tier each metric can tolerate, and make retries harmless from the start.

Advertisement

Frequently asked questions

Why is a distributed counter hard?

Increments arrive from many machines and regions at once, retries can double-count, instances can crash before saving, and a single hot row becomes a contention point. You must choose between speed, accuracy and durability, and a different choice suits A/B test tallies than billing totals.

What is the difference between best-effort and eventually consistent counting?

Best-effort counting favours speed and low cost, using in-memory caches, accepting lost or duplicated increments. Eventually consistent counting favours accuracy and durability, logging increments and aggregating them, so the total converges to the correct value shortly after the events. Use best-effort for experiments, eventual for money.

How do idempotency tokens prevent double counting?

Each increment carries a unique id. The system records ids it has applied, or uses the id as a key so a repeated write overwrites itself. If a client retries after a timeout, the duplicate has the same id and is ignored, so the count is incremented once.

Why aggregate over immutable time windows?

Once a window is closed and no new events can land in it, its total never changes. Rolling up only finalised windows lets you store a checkpoint and add later windows to it, instead of rescanning all events on every read, with no risk of missing late arrivals inside a window.

Sources & further reading

/* Comments */