Skip to content
Lab / Building BurnCap / 03
ArchitectureCost controlReliability

Adding Redis was the easy part. Not depending on it was the point.

How BurnCap keeps its own API costs and latency bounded with three cheap layers — and a Redis layer built so that losing Redis changes nothing.

Jun 19, 2026Daniel5 min readBurnCap

A tool that tells other people how to control their AI spend has an obvious obligation: don't be careless with your own. BurnCap's public API is the part most exposed to that — anyone with a key can hammer it, and one of its endpoints sits in customers' production hot path. So this post is about the three cheap layers that keep our costs and our latency bounded, and the one decision we're proudest of: adding Redis in a way that losing Redis changes nothing.

Last post we kept a model out of the product to keep it honest and cheap. This is the same instinct, pointed at infrastructure.

Layer 1: quotas, decided in one place

Every plan has a monthly event ceiling — Free 10k, Starter 100k, Growth 1M — and it's enforced at the moment of ingestion, not reconciled later on a bill. The check is boring on purpose: count this calendar month's events, compare to the plan's limit, and if the incoming batch would cross it, return a 402 quota_exceeded. Done.

The part worth copying is that all the plan numbers — quotas, history retention, seats, which features are on — live in a single PLAN_LIMITS object. Quotas, billing, and feature gates all read the same source. There's no place for "the pricing page says 100k but the enforcement says 50k" to creep in, because there's only one number.

ts
// one source of truth — quotas, billing, and feature gates all read this
export const PLAN_LIMITS = {
  free:    { events: 10_000,    historyDays: 7,   seats: 1 },
  starter: { events: 100_000,   historyDays: 90,  seats: 3 },
  growth:  { events: 1_000_000, historyDays: 365, seats: 10 },
} as const;
The pricing page customers read and the limit the API enforces are the same numbers — because they're literally the same object.

Layer 2: rate limiting, version one (no Redis)

The first rate limiter was a Postgres table. One row per (api-key, minute), atomically incremented on each request, and about 1% of requests opportunistically sweep away rows older than an hour so the table never grows. That's the whole thing. No new infrastructure, no Redis to provision, no extra service to be down at 2am.

For an MVP that was exactly right. A rate limiter is fundamentally a counter with a clock, and we already had a database that does atomic counters. Reaching for Redis on day one would have been adding a moving part to solve a problem we didn't have yet.

Layer 2.5: where it started to hurt

Two things eventually argued for more. First, the budget-check endpoint. It's designed to be called per request from a customer's app — "am I over budget before I make this expensive call?" — and every call did a fresh aggregate query against the cost data. Cheap once; less cheap a few hundred times a minute from someone's production loop.

Second, the rate limiter itself: on serverless, every single API request was doing a Postgres write just to count itself. Counting requests shouldn't cost a database write.

The wrong response would have been "add Redis to everything." Instead we wrote an actual review of every server entry point and asked, per endpoint, whether Redis made it safer, cheaper, faster, or more reliable — and the default answer was no:

  • Webhooks? Already signature-verified and idempotent. Rate-limiting a signed webhook is theater. No.
  • Auth routes? The auth library already has its own database-backed limiter — sign-in capped at 3 per 10s, and so on. No.
  • The dozens of authenticated server actions? Already gated by session, workspace, and validation, and the framework throttles them. No.
  • The daily cron and big CSV imports? A real future scaling problem — but the fix is a job queue, later, with a clear trigger to add it. Deferred, not done.

Exactly two endpoints qualified: the two reachable with just an API key that sit in an automated path. Those got Redis. Nothing else did.

Layer 3: Upstash, on a leash

The Redis layer — Upstash, over HTTP so it works in serverless — does two jobs for those two endpoints:

  1. Sliding-window rate limiting — same published limits (240/min ingest, 600/min check), same response headers, same 429 shape. Enabling it changes the mechanism, not the contract: a sliding window in Redis instead of a Postgres write per request. The limits live in one preset map, so the code and the docs can't drift.
  2. A 30-second cache on the budget check, keyed by the full scope — workspace, project, feature, customer, environment. A burst of identical checks from a production loop collapses to one DB query every 30 seconds. Invalidation is just the TTL: it self-heals within 30s of any change, and since the guardrail is advisory anyway, that staleness is fine.

Here's the part that matters, and the reason this counts as a refusal: the limiter can never take down the endpoint it protects.

ts
// a rate limiter having a bad day must degrade to a slower limiter, never a 500
async function rateLimit(key: string) {
  if (!redis) return pgRateLimit(key); // no Redis configured
  try {
    return await redisRateLimit(key);  // sliding window
  } catch {
    return pgRateLimit(key);            // Redis threw or timed out
  }
}
  • No Redis configured? Fall back to the Postgres limiter. Redis throws or times out mid-request? Catch it, fall back to Postgres. A bad day for the limiter becomes a slower limiter, never an outage.
  • The cache is an accelerator, never a dependency. Every Redis error is swallowed into a cache miss — which just means "compute it fresh," exactly what the code did before the cache existed.

So the entire Upstash layer is env-gated and optional. Locally, in CI, and for anyone self-hosting, there's no Redis at all and the behavior is byte-for-byte what it was before — Postgres limiter, fresh computes, identical responses. In production you flip on two env vars and get the faster mechanism. And if Upstash vanishes, the product doesn't notice. It's infrastructure you can turn off.

That's the same shape as the rest of BurnCap: out of your traffic, out of your prompts, out of the math — and now, able to lose its own Redis without failing a request.

The cheapest, most reliable dependency is the one you've made optional.

See it, or steal it

The plan quota meter and the limit headers are visible on demo data in about thirty seconds. If you're sizing infra for a small SaaS, the takeaway is cheap: start on the database you already have, and when you do add a faster path, gate it and give it a fallback so it can never become the reason you're down.

The same quota that's enforced at ingestion, shown back to you as a meter — one number, read everywhere.