Usage Limits in a SaaS: Metering What You Sell

If a pricing tier says '5 monitors' or '10,000 events per month', something in the app has to count and something has to enforce. A practical guide to usage metering, quota checks, and overage behavior that users understand.

Usage Limits in a SaaS: Metering What You Sell

A pricing tier means nothing until the app enforces it. "Pro plan: up to 5 monitors" is a promise in the marketing copy and a constraint in the code, and the gap between the two is where SaaS products lose either money or trust. The metering question sounds simple, "count the things per account and compare to the plan limit," but the counting method, the enforcement point, and the overage behavior each hide a decision that shapes the product for years.

Decide what you count before you build the counter

The first mistake is counting the wrong unit. Common options for the same product:

  • A count of current resources: 5 active monitors, 10 team members. This is inventory, not metering, and it lives naturally in the database as a query.
  • Usage over a period: 10,000 events per month, 100 exports per month. This needs a counter that resets, and the reset date is a design choice: billing-anniversary dates cause fewer support questions than fixed calendar months, because users see a clean bucket per invoice.
  • Seats: which mixes the two, since a seat is inventory of people plus billing that reacts to changes.

Pick the unit the customer would use to explain the plan to a colleague. If the sales line is "tracks up to 5 monitors", the unit is current resources and you are done early. If the line is "handles 100,000 events", you have real metering.

Count close to the action, not with a live query

For resource counts, a straightforward count() per check is fine, and the free-plan query plan is nothing to optimize. For event-style metering, a live COUNT(*) over a huge log table on every request is the classic performance trap.

The standard pattern is a counter row per account per period:

UsageCounter::where('account_id', $id)
    ->where('period_start', $period)
    ->increment('events', 1);

and an enforcement point that reads the counter. Two details keep this honest:

  • Increment only after the work is accepted, not when the user clicked the button; otherwise failed requests still eat the quota.
  • Wrap the check and the write in a transaction (or an atomic conditional update) so two simultaneous requests cannot both pass a "4999 < 5000" check and finish at 5001.

Daily aggregate tables, incremented as users work, cover the reporting side. Billing wants to know the monthly number, support wants to know today's number, and one table can carry both if the rollup is per day with a monthly sum.

Choose hard limits or overages deliberately

Every product needs one of two behaviors when a limit is hit, and the worst outcome is neither:

  • Hard block: the write fails with a clear message: "You have 5 of 5 monitors. Upgrade to add more." This is right when usage maps directly to your cost.
  • Soft overflow: let the work through and surface the overage: current usage, a warning banner, an email when the period closes. Right when blocking a customer mid-task loses more goodwill than the overage costs.

The failure mode to avoid is the quiet one: the limit exists in the pricing page, the app ignores it, and a long-time customer finds out in an email that their export silently failed last Tuesday. Whatever you choose, the limit has to be visible in the product, on a usage page the customer can find, with the period and the remaining amount.

Enforcement points: where the check actually runs

A quota check belongs at the write path, not in the UI. A planLimits() middleware or a form-level gate before the resource save covers the normal case, but two paths bypass it:

  • Imports and bulk operations that create resources in a loop, where a per-row check either lets the import blow past the limit or stops at row 300 with half a file applied.
  • API endpoints, which need the same enforcement plus a specific 402 or 403 response body, so integrations know exactly why the write failed.

Enforcement at the write path with a check readable by API and UI is the version that holds up. Plan-limit changes (upgrade, downgrade, cancel) go through the same code path on the way back down; a downgrade that quietly truncates data should be rare, and when it happens, explain it in the upgrade flow before it happens, not after.

The whole feature is a counter, a comparison, and a visible usage page. The code is easy; the part worth keeping is that the promise on the pricing page stays one the database can prove.