Quotas, Tiers and Fair Use
Rate limits protect the next second; quotas protect the month, the bill and the other customers.
A rate limit answers "how fast?". A quota answers "how much, in total?". They solve different problems and you need both.
What quotas protect
- Your bill. Every request has a cost — compute, storage, a paid third-party call. Without a quota, one customer's runaway script is your invoice.
- Other customers. Shared capacity means one tenant can degrade everyone.
- Your business model. Tiers only mean something if they are enforced.
Meter the thing that costs you
Request count is a poor proxy. One search over ten million rows costs more than a thousand key lookups. Meter what is actually expensive: rows scanned, compute seconds, storage bytes, outbound emails, tokens consumed by a model call.
Design the limits to be usable
- Expose the numbers. A customer cannot stay within a limit they cannot see. Provide a usage endpoint and headers.
- Warn before enforcing. Notify at 80% and 100%; do not cut a production integration off without notice.
- Fail predictably. Return
429with a clear reset time, never a silent failure or a truncated result. - Allow a grace overage for paid plans rather than a hard stop, and bill it.
Enforce at the edge
Checking a quota after doing the work protects nobody. The check belongs before the expensive operation, which means estimating the cost up front and reconciling afterwards.
Denial of wallet
In a serverless or usage-billed architecture, an attacker cannot take you down — they can make you pay. Unbounded auto-scaling with no quota is a financial vulnerability, and it is worth an alert on spend as well as on errors.
Example
// Meter what costs you, not what is easy to count.
const UNIT_COST = {
'invoice.read': 1,
'invoice.search': 10, // scans an index
'report.generate': 500, // minutes of compute
'export.csv': 1000, // scans everything, writes a file
'ai.summarise': 2000, // a paid model call
};
// A plan is a budget of units, not a count of requests.
const PLANS = {
free: { unitsPerMonth: 10_000, burstPerMinute: 60 },
pro: { unitsPerMonth: 1_000_000, burstPerMinute: 600 },
enterprise: { unitsPerMonth: Infinity, burstPerMinute: 6000 },
};When to use it
- A runaway customer script generates a five-figure cloud bill overnight because storage had no quota attached.
- A free-tier user's expensive search queries degrade a shared database until costs are metered in units rather than requests.
- A customer is warned at 80% of their monthly quota and upgrades, instead of discovering the limit when their integration breaks.
More examples
Metered quotas with reservation and reconciliation
Reserving an estimate and reconciling the actual cost is what makes metering honest for variable-cost operations like reports and model calls.
// Check BEFORE the work, reconcile AFTER — otherwise you meter what you
// already paid for.
export class QuotaService {
#key(tenantId, period) { return `quota:${tenantId}:${period}`; }
#period() { return new Date().toISOString().slice(0, 7); } // YYYY-MM
async check(tenantId, operation, estimate = null) {
const plan = PLANS[await planFor(tenantId)];
const cost = estimate ?? UNIT_COST[operation] ?? 1;
const used = Number(await redis.get(this.#key(tenantId, this.#period()))) || 0;
if (used + cost > plan.unitsPerMonth) {
// Paid plans get a grace overage rather than a hard stop.
const overage = used + cost - plan.unitsPerMonth;
if (plan.allowOverage && overage <= plan.maxOverage) {
await this.#recordOverage(tenantId, overage);
} else {
throw new QuotaExceededError({
used, limit: plan.unitsPerMonth, requested: cost,
resetsAt: startOfNextMonth(),
upgradeUrl: 'https://abc.com/billing',
});
}
}
return { cost, used, limit: plan.unitsPerMonth };
}
async consume(tenantId, units) {
const key = this.#key(tenantId, this.#period());
const total = await redis.incrby(key, Math.ceil(units));
await redis.expire(key, 70 * 24 * 3600); // keep two months
const plan = PLANS[await planFor(tenantId)];
await this.#notifyThresholds(tenantId, total, plan.unitsPerMonth);
return total;
}
async #notifyThresholds(tenantId, used, limit) {
if (!Number.isFinite(limit)) return;
for (const pct of [80, 100]) {
const threshold = limit * pct / 100;
const flag = `quota-notified:${tenantId}:${this.#period()}:${pct}`;
if (used >= threshold && await redis.set(flag, '1', 'EX', 2678400, 'NX')) {
await notifyTenantAdmins(tenantId, { type: 'quota_threshold', pct, used, limit });
}
}
}
}
// Usage: reserve an estimate, then reconcile with what it actually cost.
app.post('/api/reports', auth, async (req, res) => {
const quota = new QuotaService();
// Estimate BEFORE doing the work.
const estimate = await estimateReportCost(req.body);
const { cost } = await quota.check(req.user.tenant, 'report.generate', estimate);
const started = Date.now();
const report = await generateReport(req.body);
const actual = Math.ceil((Date.now() - started) / 100); // real compute cost
await quota.consume(req.user.tenant, actual);
res.set('X-Quota-Cost', String(actual));
res.json(report);
});
// The SET NX on the notification flag is what stops a customer receiving
// forty 'you have reached 80%' emails in one minute.Making usage visible to the customer
The straight-line projection is cheap to compute and prevents the most common support conversation: a customer discovering the limit on the day they hit it.
// A limit a customer cannot see is a limit they will hit by surprise.
// 1. Headers on every response
app.use(auth, async (req, res, next) => {
const usage = await quota.current(req.user.tenant);
res.set({
'X-Quota-Limit': String(usage.limit),
'X-Quota-Used': String(usage.used),
'X-Quota-Remaining': String(Math.max(0, usage.limit - usage.used)),
'X-Quota-Reset': usage.resetsAt.toISOString(),
});
next();
});
// 2. A usage endpoint with a breakdown they can act on
app.get('/api/usage', auth, async (req, res) => {
const usage = await quota.detailed(req.user.tenant);
res.json({
period: usage.period,
limit: usage.limit,
used: usage.used,
remaining: Math.max(0, usage.limit - usage.used),
resetsAt: usage.resetsAt,
// WHERE it went — this is what makes the number actionable
breakdown: [
{ operation: 'invoice.search', calls: 12_403, units: 124_030 },
{ operation: 'report.generate', calls: 88, units: 44_000 },
{ operation: 'export.csv', calls: 12, units: 12_000 },
],
projection: {
// Straight-line projection so they can see it coming
estimatedMonthTotal: usage.projectedTotal,
willExceed: usage.projectedTotal > usage.limit,
estimatedExceedDate: usage.projectedExceedDate,
},
});
});
// 3. A 429 that tells them what to do
class QuotaExceededError extends Error {
toResponse() {
return {
status: 429,
body: {
type: 'https://abc.com/problems/quota-exceeded',
title: 'quota_exceeded',
detail: `You have used ${this.used} of ${this.limit} units this month.`,
used: this.used,
limit: this.limit,
resetsAt: this.resetsAt,
upgradeUrl: this.upgradeUrl,
},
headers: {
'Retry-After': String(secondsUntil(this.resetsAt)),
},
};
}
}
// The breakdown is the part customers actually ask for. Without it, "you are
// at 94% of your quota" generates a support ticket instead of a fix.Denial of wallet, and spend alerting
reservedConcurrency is the control most teams miss: without it, the platform's willingness to scale is also its willingness to spend.
// In serverless and usage-billed architectures, the attack is your invoice.
// Nothing goes down; you simply pay.
// ── The shape of the attack ──────────────────────────────────────────
// 1. An endpoint calls a paid model API $0.02 / call
// 2. It auto-scales, so there is no natural ceiling
// 3. An attacker sends 5 million requests over a weekend
// 4. Monday: a $100,000 bill and no outage to have alerted anyone
// ── Defence 1: a hard ceiling on the expensive operation ─────────────
const DAILY_SPEND_CAP_CENTS = 50_000; // $500/day, whatever happens
export async function callExpensiveApi(tenantId, payload) {
const spentToday = Number(await redis.get(`spend:${today()}`)) || 0;
if (spentToday >= DAILY_SPEND_CAP_CENTS) {
await alertOncall('global daily spend cap reached — expensive API disabled');
throw new ServiceUnavailableError('temporarily_unavailable');
}
const perTenant = Number(await redis.get(`spend:${tenantId}:${today()}`)) || 0;
const tenantCap = await spendCapFor(tenantId);
if (perTenant >= tenantCap) {
throw new QuotaExceededError({ used: perTenant, limit: tenantCap });
}
const result = await provider.call(payload);
const cost = estimateCents(result.usage);
await redis.multi()
.incrby(`spend:${today()}`, cost).expire(`spend:${today()}`, 172800)
.incrby(`spend:${tenantId}:${today()}`, cost)
.expire(`spend:${tenantId}:${today()}`, 172800)
.exec();
return result;
}
// ── Defence 2: bound the infrastructure itself ───────────────────────
// serverless.yml
// functions:
// expensive:
// reservedConcurrency: 10 # a hard ceiling on parallel executions
// timeout: 30
// memorySize: 512
// Auto-scaling with no reserved concurrency is an unbounded spend commitment.
// ── Defence 3: alert on SPEND, not only on errors ────────────────────
// AWS Budgets: alert at 50%, 80% and 100% of the monthly forecast
// And an anomaly alert, because a forecast is a poor detector of a spike:
// if hourly_spend > 5 * trailing_7d_hourly_average → page someone
// ── Defence 4: expensive endpoints are never anonymous ───────────────
app.post('/api/ai/summarise',
auth, // never unauthenticated
requirePaidPlan, // never on the free tier
rateLimit({ capacity: 10, refillPerSecond: 0.1 }),
quotaCheck('ai.summarise'),
summariseHandler);
// The order matters: authenticate, then check the plan, then rate limit, then
// check the quota, and only then do the expensive thing.
Discussion