Platform

building

How services report what they used, why gauges and counters need different plumbing, and how limits are enforced.

Usage and billing

Services report. The platform prices. Neither knows the other's job — a service has no idea what a gigabyte costs, and the platform has no idea what a gigabyte is — and keeping it that way is what lets a rate change without a deploy and a service be added without touching the billing path.

The one interface

Every service implements meter, and it takes a window:

ts
meter(projectId: string, window: { since: Date; until: Date }): Promise<UsageReading[]>

until is exclusive, so two consecutive windows cannot both claim the boundary.

The nightly job walks the project list, calls meter on every running service, and prices what comes back by metric name:

ts
const RATES = {
  "files.storage":    storageCostCents,
  "files.bandwidth":  bandwidthCostCents,
  "files.requests":   requestCostCents,
  "files.transforms": transformCostCents,
  "auth.users":       userCostCents,
};

That string is the whole join between the two halves, which is a real risk: a rename on either side prices nothing, charges nobody, and looks exactly like a quiet month. Two things guard it. A metric reported with no rate is logged as an error and counted in the job's result rather than silently treated as free, and a unit test asserts the two lists still match in both directions.

This replaced a billing job that imported the files service directly and ran the rate functions inline. It worked because there was one service, and the second would have been added the same way.

Two kinds of usage

Conflating these is the single most expensive mistake available here.

Gauges

A current value: storage bytes, seats, active users.

A missed reading costs nothing, because the next one is absolute and self-correcting. There is no accumulation to get wrong, so a gauge carries no idempotency key — there is nothing a repeat could double.

ts
{ metric: "files.storage", kind: "gauge", value: 8_402_113, unit: "bytes" }

Storage is reported averaged across the window, not as today's total. A customer who held a terabyte for one hour did not hold a terabyte for the day.

Counters

A total accumulated inside the window: bandwidth served, requests handled, renditions built.

Every counter carries an idempotency key. A report counted twice is money taken twice, and retries are normal rather than exceptional.

ts
{
  metric: "files.bandwidth",
  kind: "counter",
  value: 1_048_576,
  unit: "bytes",
  idempotencyKey: "files.bandwidth:proj_WXm71C:2026-08-16",
}

The files service used to report bandwidth as a gauge, which is exactly the conflation this section warns about.

What files reports

MetricKindMeasured from
files.storagegaugeThe daily snapshot's billable bytes — a walk of the bucket, so renditions count, floored per file
files.bandwidthcounterCloudFront's own access logs
files.requestscounterThe same logs
files.transformscounterThe day-over-day rise in variant objects

Two of those need explaining.

Storage is billed on the billable figure, not the occupied one. The per-file minimum is what makes a million tiny files pay for the per-object overhead they actually cost; charging the raw sum gives it away.

Renditions are counted by their rise, because the generator tells nobody. Variants are built on demand and written straight to the bucket, so the only record that work happened is that there are more objects than yesterday. Only rises count — a day that deleted variants produces a fall, and billing a negative transform makes no sense.

That makes a project's first counted reading a trap: it is a standing total, not a day's work. It becomes the baseline and is charged nothing, which under-bills exactly one period, once. Getting the condition wrong here billed a dev project 139 renditions built over a fortnight as a single day.

What auth reports

MetricKindMeasured from
auth.usersgaugecount(*) of the project's own user table, taken daily and cached

One metric, and a gauge: accounts stored. A counter would charge for the same person again every month, which is not what storing them costs.

Stored users, not monthly active ones, and the reason is that this should be boring. Activity billing needs a definition of "active", a window, and a guarantee that whatever rows the definition reads survive long enough to be counted — three moving parts, each able to change a customer's bill without anybody touching the pricing. A count of rows has none of them: it is the same number today and tomorrow, and a customer can reproduce it with one query against their own data.

Worth reconsidering if a customer or a competitor gives a reason. Not before.

The trade it makes is that a stored-user bill has no natural decay — dormant accounts accrue forever unless somebody removes them. That is why deleting a user is part of the API rather than a later nicety: a customer can remove accounts from their own server, and the count falls at the next nightly count. Without that, this pricing model would only ever ratchet up.

The count is cached rather than taken live, because the users live in a database per project and those scale to zero: counting live would start a compute per project every time anybody asked, including a console page load. The auth-users job wakes each one once a night; meter reads the figure. A count more than 36 hours old is reported in the job's result, because a silently failing counter under-bills and nobody complains about that direction.

The $1 project minimum

A project with auth provisioned is charged max($1, users × $0.02) a month.

A database per project is what makes one customer's users structurally unreachable from another's. It is also a Postgres compute that costs us something at zero users, so usage-only pricing loses money on every quiet project — and quiet projects are the majority of any signup list.

A floor, not a base fee: a project with $4 of users pays $4, not $5. At these rates the two cross at 50 users.

It is framed as money rather than as a quantity on purpose. This was briefly implemented as "every project counts as at least 50 users", which produces the same figure and asks a customer to believe in fifty people who do not exist. A minimum charge is the thing that is actually true.

A project without auth provisioned pays nothing, and the mechanism is worth knowing because getting it wrong would charge the minimum to every project on the platform. The minimum keys on a service reporting any reading at all, so auth returns an empty array — not a zero — for a project it has no database for. "This project has no users" and "this project has no auth" are different answers, and only one owes us a dollar. Suspended projects report nothing for the same reason.

The $1 is the least certain number in the pricing file. It rests on what a mostly-idle Neon project really costs on our plan, which is still an open question. Measure it before this bills a real account.

Monthly rates against a daily charge

A rate is quoted either per month or per event, and the difference decides whether it is prorated:

BasisMetricsProrated
monthlyfiles.storage, auth.users, the project minimumYes — by the window's share of the calendar month
eventfiles.bandwidth, files.requests, files.transformsNo — a counter's value is already the window's total

This was missing, and it was a 30× overcharge. Storage is published at $0.15 per GB-month; the charging job runs daily and charged the full monthly figure every night. A project holding one gigabyte paid about $4.50 a month against a rate that says fifteen cents.

Nothing caught it because the gauge/counter split does not describe it. A gauge answers "how much was held" — a property of the reading. This answers "over what period is the price quoted" — a property of the rate. They looked like the same distinction and are not.

Proration is against the actual length of the calendar month, not a flat 30, so a full month of daily charges sums to exactly the monthly rate rather than 31/30 of it in January and 28/30 in February. A test asserts that sum for four different month lengths, including a leap February.

Charging## Charging

Daily rather than monthly. A month of unbilled usage on a prepaid balance is a month of work nobody has paid for, which defeats the point of prepaid.

The idempotency key is the organization and the day, so a retried run collides instead of billing twice — which is what happens the first time the job times out halfway through. Costs are summed per organization, not per project: a customer with staging and production should see one line.

Deleted projects are excluded. They are charged up to the day they were deleted by the run that covered it, and billing one afterwards is how a cancelled customer gets one more invoice.

Enforcement

The platform decides the numbers, the service compares against them. Defaults are 100 GB and a million files per project, overridable per project in the limits column, and on_exceeded chooses what happens at the ceiling:

  • block — refuse further work. Correct for self-serve, where the alternative is an unbounded bill nobody agreed to.
  • allow_and_flag — serve it, record it, and tell a human. Correct for a negotiated customer, where cutting service mid-launch costs more than the overage.

Checked on writes only. A read cannot make a project more expensive, so making every list call wait on a quota lookup pays for a check that can never fire.

The entitlement is cached five minutes per process, so it is a ceiling rather than an emergency stop; the rate limit is the thing that acts immediately. Running out of credit blocks writes and leaves reads alone — a customer should still be able to fetch what they already paid to store.

What the console shows

The billing page reads the same usageFor the nightly job does, so the number a customer sees and the number they are charged are computed once. It used to run the rate functions itself, which meant two paths that would drift the first time either changed.

Reconciliation

The credit balance is a cached sum of an append-only ledger, and anything derived drifts — a crash between the insert and the update, a hand-edited row. Rebuilding the sum is cheap and the answer is either "agrees" or a number somebody needs to look at.

Logs are not usage

Usage is a handful of numbers per project per day. Logs are orders of magnitude larger, carry retention and privacy obligations, and scale with a customer's traffic rather than with revenue.

Services own their logs and expose a query interface. The platform provides a unified viewer, not unified storage — otherwise it quietly becomes a log aggregation product that nobody chose to build.