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,
  "data.storage":     dataStorageCostCents,
  "data.compute":     dataComputeCostCents,
};

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 studio 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.

What the database reports

MetricKindMeasured from
data.storagecounterBranch storage and snapshots, in byte-months
data.restorecounterPoint-in-time restore history, in byte-months
data.computecounterCompute-unit seconds while awake
data.transfercounterBytes read out over the public internet

These are the same figures the database provider invoices us on, read from their consumption API and cached hourly. Every other service on this platform is metered by something we count ourselves; this one is metered by the meter we are being billed against, which is what makes each customer's margin a fact rather than an average.

Storage is a counter, and that surprises people. The figures arrive as byte-months — byte-hours already divided by 744 — so a window's number is what that window cost to hold, not what is being held. It is a total, like transfer, rather than a standing quantity like files.storage. Priced as a monthly rate it would be prorated a second time and undercharge by roughly the length of the month. A gigabyte held for half a month is half a GB-month, and gigabytes here are decimal, because that is the unit the storage underneath is metered in.

A read that fails is not a zero. If the consumption API answers in a shape we do not recognise, or a database has existed long enough to have been metered and never has, the meter raises rather than reporting nothing. Zeroes would bill the project minimum and hide a broken meter for a month; a raised error skips one line of one invoice and puts the reason in a log. Under-billing is the direction nobody complains about, which is the direction that goes unnoticed.

The $5 project minimum

A project with a database is charged max($5, storage + compute + transfer) a month, on the same floor-not-a-base-fee principle as auth's dollar.

Higher than auth's for a plain reason: an idle auth database holds a handful of rows, while an idle customer database holds their product. That is storage we pay for every month whether or not anybody queries it, and five dollars is roughly ten gigabytes of it.

A project without a database pays nothing, by the same empty-array mechanism auth uses.

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.transforms, all four database.*No — the 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 studio 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.