# Conflict Free Counter

An eventually-consistent, contention-free counter component for Bijection.

- Package: @bijectionhq/conflict-free-counter
- Category: Database & Backend
- Status: Reference
- License: Apache-2.0
- Bijection source (repository access required): https://github.com/bijectionhq/conflict-free-counter/tree/f95146c796529812ee01dbf2ad763230c4723cb9
- Upstream: https://github.com/stack1ng/convex-conflict-free-counter/tree/1b23f8137d13895105091c40a5835fc02f323144
- Documentation: https://docs.bijection.com/components/using
- Directory: https://bijection.com/components/conflict-free-counter

## Get the source

Bijection repository access is required. Request access at https://bijection.com/contact.

```sh
git clone https://github.com/bijectionhq/conflict-free-counter.git
cd conflict-free-counter
git checkout f95146c796529812ee01dbf2ad763230c4723cb9
```

## Before using this component

Read the package README at the recorded revision for setup, dependencies, and API examples. Upstream documentation describes the original package and may differ from the Bijection adaptation. This is a source candidate for evaluation. Catalog inclusion does not establish npm availability or runtime qualification. Check compatibility with your SDK and deployment before adoption. External providers run their own compute.

## Upstream package documentation

Recorded 2026-10-23. This describes convex-conflict-free-counter; check the Bijection adaptation separately.

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

- 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

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 README

# Convex Conflict-Free Counter Component

An **eventually-consistent, contention-free counter** for
[Convex](https://convex.dev). 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`](https://www.convex.dev/components/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](https://github.com/stack1ng/convex-conflict-free-counter/issues).

## 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](https://docs.convex.dev/get-started).

Run `npm create convex` or follow any of the
[quickstarts](https://docs.convex.dev/home) to set one up.

## Installation

Install the component package:

```sh
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`:

```ts
// 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](https://docs.convex.dev/components/using) for how an
installed component is wired into your app.

Instantiate the client (typically in its own module so every function shares
one instance):

```ts
// 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:

```ts
// 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:

```ts
await counter.addMany(ctx, [
  { key: "views:home", delta: 1 },
  { key: "views:total", delta: 1 },
]);
```

## Reading counters

```ts
// 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:

```ts
// 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

```ts
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](https://stack.convex.dev/triggers)), you can buffer
deltas in memory and flush them with one component call at the end:

```ts
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 `add` is durably committed in your
  mutation's transaction. The count always converges to the true total.
- **Reads are exact when `fullyConsistent` is `true`.** This is the common
  case: the snapshot plus the scanned log tail covers every committed write.
- **Reads can lag when `fullyConsistent` is `false`.** This happens when more
  log entries exist than the read was willing (or able) to scan. The lag is
  bounded by the compaction cadence — roughly `compactionDelay` plus the time
  to drain the backlog.
- **Counts are floats.** Deltas are Convex `number`s (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`](https://docs.convex.dev/functions/testing) suites —
no running backend required.

First expose your app's modules once, in a shared setup file:

```ts
// 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:

```ts
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

```sh
npm i
npm run dev
```

See
[example/convex/example.ts](https://github.com/stack1ng/convex-conflict-free-counter/blob/main/example/convex/example.ts)
for a complete usage example.