Conflict Free Counter
An eventually-consistent, contention-free counter component for Bijection.
git clone https://github.com/bijectionhq/conflict-free-counter.git
cd conflict-free-counter
git checkout f95146c796529812ee01dbf2ad763230c4723cb9Bijection repository access is required. Request access.
Description
An eventually-consistent, contention-free counter component for Bijection.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: bijectionhq/conflict-free-counter. Based on stack1ng/convex-conflict-free-counter. License: Apache-2.0. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
An eventually-consistent, contention-free counter component for Convex.
Explore the demo ↗Upstream overview
From the upstream package documentation.
Conflict Free Counter provides an eventually-consistent, contention-free counter built on a conflict-free log architecture. Every call to add appends a row to an internal log rather than updating a shared document, eliminating write contention entirely. A scheduled compaction pipeline periodically folds the log into a snapshot, and reads return snapshot + visible log entries along with a fullyConsistent flag indicating whether the returned count reflects every committed write.
Use cases
From the upstream package documentation.
- Tracking high-throughput event counts (page views, signups, API calls) where many concurrent mutations would otherwise cause document contention and retries
- Incrementing and decrementing credit or quota balances across concurrent operations without serializing writes through a single document
- Aggregating metrics or tallies in systems using database triggers, where a single mutation may update the same counter key many times and needs to batch those updates efficiently
- Building dashboards or stats queries where reads can tolerate a few seconds of lag in exchange for writes that never block or retry
- Replacing a manually sharded counter when you want throughput to scale automatically without choosing or rebalancing shard counts
How it works
From the upstream package documentation.
Install the package and register it in convex/convex.config.ts using app.use(conflictFreeCounter). Then instantiate new ConflictFreeCounter(components.conflictFreeCounter) once in a shared module and import that instance across your mutations and queries.
Writes use counter.add(ctx, key, delta) or counter.addMany(ctx, entries) from any mutation or action. Each call appends to an internal log document rather than reading and updating a shared counter, so concurrent mutations never touch the same document and write conflicts are structurally impossible. For hot mutations that update the same key repeatedly, counter.bindDeltasBuffer(ctx) and counter.addBuffered coalesce increments in memory before a single flushDeltas call writes one log row per key.
Reads use counter.count(ctx, key) which returns { count, fullyConsistent }. By default, reads scan as much of the uncompacted log as the transaction's read budget allows on top of the latest snapshot. You can cap this with logScanLimit to reduce read cost, or set logScanLimit: 0 to read the snapshot only, which is useful for reactive subscriptions that should update once per compaction rather than on every increment.
Compaction runs on a scheduled background pipeline controlled by compactionDelay (default 15 seconds). After a write burst, reads may temporarily report fullyConsistent: false until compaction catches up, but no writes are ever lost. The package ships a /test entry point for use with convex-test, and fake timer support allows controlled compaction stepping in unit tests.
Upstream package FAQ
How is convex-conflict-free-counter different from the Convex sharded counter component?
convex-conflict-free-counter does not require you to choose a shard count upfront or rebalance shards as load changes. Throughput scales automatically because every write appends a new log row rather than contending on a fixed set of shards. The trade-off is that reads can lag by up to the compaction delay (default 15 seconds) during high write bursts, while sharded-counter trades read contention against shard count instead.
Are writes ever lost if the compaction has not run yet?
No. Every call to counter.add() is committed in the calling mutation's transaction before the function returns. The compaction pipeline only consolidates existing log entries into a snapshot; it does not gate durability. The count always converges to the true total regardless of compaction timing.
What does the fullyConsistent flag mean on counter.count()?
fullyConsistent: true means the returned count includes every write committed before the query started, because the snapshot plus the scanned log tail covered all entries. fullyConsistent: false means more log entries existed than the read scanned, so the count may temporarily be missing recent increments. The lag is bounded by the compactionDelay setting, which defaults to 15 seconds.
How do I install and register the convex-conflict-free-counter component?
Run npm install convex-conflict-free-counter, then add app.use(conflictFreeCounter) in your convex/convex.config.ts file after importing from convex-conflict-free-counter/convex.config. Instantiate the client with new ConflictFreeCounter(components.conflictFreeCounter) in a shared module and import that instance in your mutation and query files.
Can I use convex-conflict-free-counter to subtract from a counter or track negative deltas?
Yes. The delta argument to counter.add() accepts any Convex number, including negative values. For example, counter.add(ctx, 'credits:remaining', -3) subtracts 3 from that key. Deltas are IEEE 754 doubles, so integer counts remain exact up to 2^53.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Conflict Free Counter with Bijection.
Read: https://bijection.com/components/conflict-free-counter/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from stack1ng/convex-conflict-free-counter, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
Convex Conflict-Free Counter Component
An eventually-consistent, contention-free counter for Convex. Increment it from as many concurrent mutations as you like — writes never conflict with each other, so your mutations never slow down or retry because of counter contention.
Use it for metrics, event counts, tallies, quotas — anywhere many writers bump the same counter and reads can tolerate a few seconds of lag (and every read tells you whether it lagged).
How it works: every add appends a row to an internal log, which is
conflict-free by construction. A scheduled compaction pipeline periodically
folds the log into a per-key snapshot behind a lease, so the log stays small.
Reads return snapshot + visible log entries along with a fullyConsistent
flag — true when the returned count reflects every write committed before
the read, false when the uncompacted log may have been larger than the
scan budget, so the count may (temporarily) be missing recent increments.
Compared to
@convex-dev/sharded-counter:
- No shard count to choose or rebalance — throughput scales automatically with write volume.
- Reads are exact whenever the backlog fits in the read budget (and say so
via
fullyConsistent), instead of trading read contention against shard count. - The trade-off: after a burst of writes, a read may briefly lag behind by up to the compaction delay (default 15s). Writes are never lost — the count always converges.
Found a bug? Feature request? File it here.
Pre-requisite: Convex
You'll need an existing Convex project to use the component. Convex is a hosted backend platform, including a database, serverless functions, and a ton more you can learn about here.
Run npm create convex or follow any of the
quickstarts to set one up.
Installation
Install the component package:
npm install convex-conflict-free-counter
Create a convex.config.ts file in your app's convex/ folder and install
the component by calling use:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import conflictFreeCounter from "convex-conflict-free-counter/convex.config";
const app = defineApp();
app.use(conflictFreeCounter);
export default app;
New to Convex components? See the using components guide for how an installed component is wired into your app.
Instantiate the client (typically in its own module so every function shares one instance):
// convex/counter.ts
import { ConflictFreeCounter } from "convex-conflict-free-counter";
import { components } from "./_generated/api";
export const counter = new ConflictFreeCounter(components.conflictFreeCounter);
Updating counters
Add to (or subtract from) a counter from any mutation or action:
// convex/events.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { counter } from "./counter";
export const recordEvent = mutation({
args: { kind: v.string() },
handler: async (ctx, { kind }) => {
await counter.add(ctx, `events:${kind}`); // delta defaults to 1
await counter.add(ctx, "events:total", 1);
await counter.add(ctx, "credits:remaining", -3);
},
});
Batch several updates into a single component call:
await counter.addMany(ctx, [
{ key: "views:home", delta: 1 },
{ key: "views:total", delta: 1 },
]);
Reading counters
// convex/stats.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { counter } from "./counter";
export const eventCount = query({
args: { kind: v.string() },
handler: async (ctx, { kind }) => {
const { count, fullyConsistent } = await counter.count(
ctx,
`events:${kind}`,
);
// `fullyConsistent` is true when `count` reflects every write committed
// before this query started.
return { count, fullyConsistent };
},
});
By default, reads scan as much of the uncompacted log as the transaction's read budget allows, so they are exact except during very large write bursts. You can bound (or skip) the log scan for cheaper reads:
// Read at most 100 uncompacted log entries on top of the snapshot:
await counter.count(ctx, key, { logScanLimit: 100 });
// Snapshot only — cheapest possible read, lags by up to the compaction delay:
await counter.count(ctx, key, { logScanLimit: 0 });
Note that a query that scans the log re-runs whenever the counter changes, as
any reactive Convex query would. Use logScanLimit: 0 for subscriptions that
should only update once per compaction rather than on every increment.
Configuration
export const counter = new ConflictFreeCounter(components.conflictFreeCounter, {
// Wait this long after a write before compacting (default: 15s).
// Longer = fewer, larger compactions. Shorter = reads converge faster.
compactionDelay: 15_000,
// How long one compaction round may take before its lease can be stolen
// (default: 60s). You shouldn't normally need to change this.
compactionLeaseDuration: 60_000,
// Default `logScanLimit` for count() (default: unlimited, i.e. scan
// within the transaction's read budget).
defaultLogScanLimit: undefined,
});
Buffering updates in hot mutations
If one mutation updates the same keys many times (for example from database triggers), you can buffer deltas in memory and flush them with one component call at the end:
const bufferedCtx = counter.bindDeltasBuffer(ctx);
await counter.addBuffered(bufferedCtx, "tasks:completed");
await counter.addBuffered(bufferedCtx, "tasks:completed"); // coalesced
await counter.addBuffered(bufferedCtx, "tasks:pending", -2);
await counter.flushDeltas(bufferedCtx); // one addMany call, 2 log rows
Consistency model
- Writes are never lost. Every
addis durably committed in your mutation's transaction. The count always converges to the true total. - Reads are exact when
fullyConsistentistrue. This is the common case: the snapshot plus the scanned log tail covers every committed write. - Reads can lag when
fullyConsistentisfalse. This happens when more log entries exist than the read was willing (or able) to scan. The lag is bounded by the compaction cadence — roughlycompactionDelayplus the time to drain the backlog. - Counts are floats. Deltas are Convex
numbers (IEEE 754 doubles), so integer counts stay exact up to 2^53.
Testing your app
The package ships a /test entry point so you can register the component in
your own convex-test suites —
no running backend required.
First expose your app's modules once, in a shared setup file:
// convex/test.setup.ts
/// <reference types="vite/client" />
export const modules = import.meta.glob("./**/*.ts");
Then register the component on each test instance and drive its scheduled compaction with fake timers:
import { convexTest } from "convex-test";
import { expect, test, vi } from "vitest";
import conflictFreeCounter from "convex-conflict-free-counter/test";
import schema from "./schema";
import { modules } from "./test.setup";
import { api } from "./_generated/api";
test("counts events", async () => {
const t = convexTest(schema, modules);
// Pass the name here if you mounted it with `app.use(counter, { name })`.
conflictFreeCounter.register(t);
await t.mutation(api.events.record, { kind: "signup" });
// Compaction runs through the scheduler. Advance timers one step at a
// time so each lease-guarded compaction step runs in order (jumping the
// whole timeline with `vi.runAllTimers` would expire leases early).
vi.useFakeTimers();
await t.finishAllScheduledFunctions(() => vi.advanceTimersToNextTimer());
vi.useRealTimers();
expect(await t.query(api.events.count, { kind: "signup" })).toMatchObject({
count: 1,
});
});
register(t, name?) calls t.registerComponent under the hood; the default
name is conflictFreeCounter.
Running the example
npm i
npm run dev
See example/convex/example.ts for a complete usage example.