Usage Metering on a Key-Value Store: Counters, Idempotency, and When You Outgrow It
A usage counter is a single key and an atomic increment - exactly what KV is fastest at. The counter patterns that work (month-to-date totals, idempotency keys that stop retries double-billing, TTL rate-limit windows, pre-aggregated rollups), why disk-first persistence matters so counters are never evicted, and the honest line where a counter stops being enough and you need a purpose-built meter with an audit trail.
Almost every product that charges by usage starts the same way: someone needs a number. How many API calls did this account make this month? How many tokens, how many seats actively used, how many rows synced? The instinct is to reach for the billing database. The better first move is a key-value store, because the thing you actually need - a fast, per-account, per-meter counter - is shaped exactly like KV.
This article is about doing that well: the counter patterns that work, the idempotency trap that double-bills you, and - just as important - the line where a KV counter stops being enough and you need a real meter.
Why KV Is the Right Tool for Usage Counters
A usage counter is a single key and an atomic increment. That is the one thing key-value stores are fastest at.
- Atomic counters by
usage:{account}:{meter}:{period}- oneINCRper event. No joins, no row locks, no scan. - Idempotency keys by
seen:{event_id}- store the id you have already counted so a retried request does not increment twice. - Windowed counters with TTL by
rl:{account}:{minute}- per-minute or per-day buckets that expire on their own, for rate limits and rolling quotas. - Pre-aggregated rollups by
usage:{account}:{meter}:2026-06- the month-to-date total a dashboard reads instantly instead of summing raw rows.
Every one of these is a single-key operation. You are not paying a relational database's coordination cost to do arithmetic.
The Counter Patterns That Actually Work
Month-to-date totals
Key the counter by account, meter, and calendar period, and increment on every event:
usage:acct_123:api_calls:2026-06→INCR
A dashboard reads one key for the live total. A new period is just a new key - no resets, no cron job to zero anything out.
Idempotency: the trap that double-bills
Usage collectors retry. Networks drop acks. If you INCR on every received request, a retried batch counts the same usage twice and your customer is overbilled. The fix is a dedupe key checked before the increment:
- On event
evt_abc: ifseen:evt_abcexists, stop. Otherwise setseen:evt_abc(with a TTL longer than your retry window) and thenINCRthe counter.
This is the single most important pattern in usage metering, and it is why a persistent store matters: if your dedupe keys live in memory and get evicted under pressure, retries you already counted look new again. (We go deeper in idempotency keys with a KV store.)
Rate limits and rolling quotas
Per-window buckets with TTL give you rate limiting for free:
rl:acct_123:2026-06-15T14:32→INCR, set TTL 120s, reject if over the limit.
The key expires itself. No sweeper job, no growing table of stale windows.
Why Disk-First Persistence Matters Here
In-memory stores treat counters as cache: under memory pressure they evict, and an evicted counter is lost usage - usage you will never bill for, or a dedupe key whose loss causes a double-count. For anything that touches money, eviction-as-default is the wrong posture.
A disk-first store like BaseKV keeps counters persistent from the first write:
- No silent loss of counters or idempotency keys on restart or memory pressure.
- No eviction surprises - your month-to-date total does not evaporate during a traffic spike.
- Predictable cost - flat plans instead of paying for RAM headroom you only need to avoid evictions.
- Exportable - your counters come out as JSON/CSV whenever you want.
For usage data specifically, "persistent by default" is not a nice-to-have; it is the difference between a number you can trust and a number that is sometimes wrong.
When a KV Counter Stops Being Enough
Here is the honest boundary, because hand-rolling past it is how teams get hurt. A KV counter gives you a total. Billing-grade metering needs more than a total:
- Audit trail. When a customer disputes a charge, "the counter says 2.4M" is not evidence. You need the immutable per-event record behind the number. A counter has thrown the events away.
- Corrections and late data. A counter has no concept of a correction that nets against an earlier event, or of a period you have already invoiced. A late event silently changes a number you already billed.
- Reconciliation. You cannot ask a counter "explain this number" or check a fast rollup against the raw truth - there is no raw to check against.
So draw the line deliberately:
- Use a KV store for live counters, dashboards, soft limits, rate limits, idempotency keys, and pre-aggregated rollups. This is the fast path, and BaseKV is a great fit for it - persistent, predictable, exportable.
- Graduate to a purpose-built meter (an append-only, immutable event store with corrections, period close, and raw-vs-rollup reconciliation) when the counter is feeding invoices a customer can dispute. That is a different tool with a different job.
The mistake is not using KV for usage - KV is the right tool for the counter layer. The mistake is asking a counter to be your billing system of record. Keep the fast counter on a store built for fast, durable single-key writes, and keep the auditable event record where audit trails belong.
See also: key-value database use cases and using a disk-backed Redis alternative.