A monthly API budget cap tells you when you have already lost. By the time one power user burns through $40 of your $50 budget on September 3rd, the cap has stopped being a guardrail and started being a post-mortem.
Per-user guardrails fix this at the source. They track spend per user per day, warn before the limit is hit, and switch heavy users to a cheaper model rather than cutting them off cold. This post shows how to build that in Node.js with Redis in under 60 lines of middleware.
Why per-user limits beat a global monthly cap
A single account-level cap gives you one lever. If 1 of your 100 users runs a batch job hitting Claude Fable 5 at 250 requests on the third of the month, the other 99 users absorb that cost in degraded service or a surprise invoice.
Per-user guardrails solve three things a global cap cannot:
- Attribution: you know which user caused the overage, not just that it happened.
- Isolation: heavy users do not crowd out light ones mid-month.
- Graduated response: instead of a hard stop, you downgrade the user's model tier and keep them working.
The pattern here uses a daily token budget. Daily resets make the limit easy to explain ("you have X tokens per day") and keep Redis memory predictable with short TTLs. Weekly or monthly work too; swap the date key accordingly.
The data model
Each user gets two Redis keys:
user:{userId}:tokens:{YYYY-MM-DD} → integer (tokens used today)
user:{userId}:tier:{YYYY-MM-DD} → string ("frontier" | "budget")
The first key accumulates tokens per request. The second records whether the user has crossed the soft limit today. Once downgraded, every subsequent call that day routes to the budget model regardless of what the app requests. Both keys expire after 25 hours, so Redis never accumulates stale data.
The middleware: guardCost and recordUsage
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const SOFT_LIMIT = 50_000; // tokens: triggers model downgrade
const HARD_LIMIT = 62_500; // tokens: hard stop
const BUDGET_MODEL = 'anthropic/claude-haiku-4-5';
export async function guardCost(userId, preferredModel) {
const day = new Date().toISOString().slice(0, 10);
const key = `user:${userId}:tokens:${day}`;
const tier = `user:${userId}:tier:${day}`;
const [used, forcedBudget] = await Promise.all([
redis.get(key),
redis.get(tier),
]);
const usedInt = parseInt(used ?? '0', 10);
if (usedInt >= HARD_LIMIT) {
return { allowed: false, model: null, usedTokens: usedInt };
}
const downgraded = forcedBudget === 'budget' || usedInt >= SOFT_LIMIT;
const model = downgraded ? BUDGET_MODEL : preferredModel;
return { allowed: true, model, downgraded, usedTokens: usedInt };
}
export async function recordUsage(userId, tokensUsed) {
const day = new Date().toISOString().slice(0, 10);
const key = `user:${userId}:tokens:${day}`;
const tier = `user:${userId}:tier:${day}`;
const newTotal = await redis.incrBy(key, tokensUsed);
await redis.expire(key, 60 * 60 * 25);
if (newTotal >= SOFT_LIMIT) {
await redis.set(tier, 'budget', { EX: 60 * 60 * 25 });
}
return newTotal;
}
Two functions, no shared mutable state beyond Redis. guardCost runs before the LLM call; recordUsage runs after, using the token count from the response.
Wiring it into a route
app.post('/api/chat', async (req, res) => {
const { userId, message, preferredModel } = req.body;
const guard = await guardCost(userId, preferredModel);
if (!guard.allowed) {
return res.status(429).json({
error: 'Daily token budget exhausted. Resets at midnight UTC.',
usedToday: guard.usedTokens,
limit: HARD_LIMIT,
});
}
if (guard.downgraded) {
res.setHeader('X-Model-Downgraded', 'true');
}
const response = await callLLM({
model: guard.model,
messages: [{ role: 'user', content: message }],
});
await recordUsage(userId, response.usage.total_tokens);
res.json({ reply: response.output, model: guard.model });
});
The downgrade is invisible to the rest of your app logic; only the model string changes. Surface the X-Model-Downgraded header to your frontend if you want to show a "lite mode" badge when a user has hit their soft limit.
Setting the right thresholds
The numbers above are illustrative. Set your limits based on your actual cost curve.
At Haiku 4.5's pricing ($0.80 / $4 per million input/output tokens), 62,500 tokens costs roughly $0.05 for pure input or $0.25 mixed. At Fable 5 rates ($10 / $50 per million), the same 62,500 tokens costs $0.63 for input. Same key, different stakes.
A practical starting point for SaaS apps: set the hard limit where one user's daily compute cost equals their monthly subscription fee divided by 30. If they pay $20/month, that is $0.67/day of compute budget. Set hard stop at $0.60, soft limit at $0.48, and you are covered for average usage without capping typical workloads.
Surface the remaining budget to users
A hard stop with no context breaks trust. Before you hit the limit, expose the remaining budget in response headers so your frontend can render a usage meter:
res.setHeader('X-Budget-Used', guard.usedTokens);
res.setHeader('X-Budget-Limit', HARD_LIMIT);
When users can see they are at 75%, many slow down voluntarily. That means fewer forced downgrades and fewer support tickets asking why their requests stopped working.
What this does not handle
This middleware tracks tokens, not dollars directly. If you route to multiple model tiers with different per-token rates, a token count across both undercounts cost when the frontier model was used more. Two options: convert tokens to cost before incrementing (multiply by the model's per-token rate), or maintain separate buckets per model tier.
For multi-provider cost tracking with dollar precision, the LLM observability middleware logs per-call cost as a float. You can pipe those logged costs into a separate Redis sum key to run dollar-based limits alongside these token-based ones.
If your users can trigger agents that call tools in loops, token counts also undercount. An 8-step agent run costs 3 to 5 times what a single-turn call does because tool results feed back into the context on each step. The LLM agent cost breakdown shows the full math; for agents, track cost-per-session rather than cost-per-request.
The layer below: provider rate limits
Per-user guardrails sit above provider-level rate limiting. This middleware stops one user from consuming your account budget; the four production LLM rate-limit patterns handle what happens when your account itself hits a provider's TPM ceiling. Both layers are needed. A user who stays under their daily budget can still trigger a 429 if enough concurrent users share the same provider tier.
Add guardrails before your usage grows. Retrofitting budget controls after a surprise four-figure month is possible, but more painful than shipping them as part of your initial LLM integration. Start with per-request cost visibility through the LLMTest proxy, then layer these Redis guardrails on top for user-level isolation.